release: promote dev to main for v2.0.0 (#1426)

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-10-01 11:19:09 +03:00
1 parent ed915d0816
commit 728daa9acd
315 files changed
+65173 -7909

No files matched your search

@@ -23,6 +23,7 @@ class NavigationControllerTest {
NavigationController.backStack = null
NavigationController.consumePendingSessionId()
NavigationController.consumePendingNewChatNavigation()
NavigationController.consumeChatReturnScreen()
}
@After
@@ -30,6 +31,7 @@ class NavigationControllerTest {
NavigationController.backStack = null
NavigationController.consumePendingSessionId()
NavigationController.consumePendingNewChatNavigation()
NavigationController.consumeChatReturnScreen()
}
// ── Dedup guard: navigateTo with same key ──────────────────────────────
@@ -171,6 +173,92 @@ class NavigationControllerTest {
assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true)
}
// ── Chat return screen: back from a list-opened chat returns to that list ──
@Test
fun `openChatSession from a list arms the list as the return screen`() {
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.openChatSession("stored-session")
assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull())
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
assertEquals(HistoryScreen, NavigationController.consumeChatReturnScreen())
assertNull("consume must disarm", NavigationController.consumeChatReturnScreen())
}
@Test
fun `openChatSession from Bots arms Bots`() {
NavigationController.backStack = NavBackStack<NavKey>(BotsScreen)
NavigationController.openChatSession("bot-session")
assertEquals(BotsScreen, NavigationController.consumeChatReturnScreen())
}
@Test
fun `openChatSession from chat arms nothing`() {
NavigationController.backStack = NavBackStack<NavKey>(ChatScreen)
NavigationController.openChatSession("stored-session")
assertNull(NavigationController.consumeChatReturnScreen())
}
@Test
fun `notification open never arms a return screen`() {
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.openChatSessionFromNotification("stored-session")
assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull())
assertNull(NavigationController.consumeChatReturnScreen())
}
@Test
fun `drawer navigation disarms a stale return screen`() {
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.openChatSession("stored-session")
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
// User opens the drawer and picks Skills instead of pressing back.
NavigationController.navigateTo(SkillsScreen)
assertNull("explicit navigation must invalidate the return", NavigationController.consumeChatReturnScreen())
}
@Test
fun `re-tapping chat in the drawer also disarms the return`() {
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.openChatSession("stored-session")
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
// Same-screen dedup would skip the body — the clear must still run.
NavigationController.navigateTo(ChatScreen)
assertNull(NavigationController.consumeChatReturnScreen())
}
@Test
fun `openNewChat does not arm a return screen`() {
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.openNewChat()
assertNull(NavigationController.consumeChatReturnScreen())
}
@Test
fun `resetTo disarms the return screen`() {
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.openChatSession("stored-session")
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
NavigationController.resetTo(ChatScreen)
assertNull(NavigationController.consumeChatReturnScreen())
}
// ── resetTo: atomic clear + navigate ──────────────────────────────────
@Test
@@ -0,0 +1,37 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class McpServerParsingTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `servers decode tools as null, list, or filter object`() {
val body =
"""{"servers":[
{"name":"a","enabled":true,"tools":null},
{"name":"b","enabled":true,"tools":["x","y"]},
{"name":"c","enabled":true,"tools":{"exclude":["docs","*_radar*"]}},
{"name":"d","enabled":true,"tools":{"include":["one","two","three"]}}
]}"""
val servers = json.decodeFromString<McpServersResponse>(body).servers
assertEquals(listOf<Int?>(null, 2, null, 3), servers.map { it.toolCount })
}
@Test
fun `catalog entry decodes backend required_env and installed flags`() {
val body =
"""{"entries":[{"name":"gh","description":"d","source":"nous","transport":"stdio",
"required_env":[{"name":"GH_TOKEN","prompt":"Token","required":true}],
"installed":true,"enabled":false,"needs_install":false}],"diagnostics":[]}"""
val entry = json.decodeFromString<McpCatalogResponse>(body).entries.single()
assertTrue(entry.installed)
assertEquals("GH_TOKEN", entry.env?.single()?.key)
assertEquals("Token", entry.env?.single()?.label)
assertNull(entry.url)
}
}
@@ -622,4 +622,33 @@ class ModelSerializationTest {
json.decodeFromString<ToggleSkillRequest>(jsonInput)
}
}
@Test
fun moaConfigDecodesNullTemperaturesAndNewFields() {
val payload =
"""
{"default_preset":"default","active_preset":"","privacy_filter":"",
"presets":{"default":{"enabled":true,
"reference_models":[{"provider":"openrouter","model":"x/y","enabled":false,"reasoning_effort":"high"}],
"aggregator":{"provider":"openrouter","model":"a/b"},
"reference_temperature":null,"aggregator_temperature":null,
"reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3"}},
"reference_models":[{"provider":"openrouter","model":"x/y","enabled":true}],
"aggregator":{"provider":"openrouter","model":"a/b"},
"reference_temperature":null,"aggregator_temperature":null,
"reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3","enabled":true}
""".trimIndent()
val cfg = json.decodeFromString<MoaConfigResponse>(payload)
assertNull(cfg.reference_temperature)
assertNull(cfg.aggregator_temperature)
assertEquals("every_n:3", cfg.fanout)
val preset = cfg.presets.getValue("default")
assertEquals("silent", preset.degraded_reference_policy)
assertEquals(false, preset.reference_models.first().enabled)
assertEquals("high", preset.reference_models.first().reasoning_effort)
// Round-trip keeps the non-default knobs so a save doesn't reset them.
val back = json.encodeToString(cfg)
assertTrue(back.contains("every_n:3"))
assertTrue(back.contains("silent"))
}
}
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* Wire-shape pins for the destructive session ops. The backend reads the
* profile off these request BODIES (`SessionPrune` / `BulkDeleteSessions`) —
* the `?profile=` query rewrite never reaches them — and its prune model is
* `older_than_days` (the old `days` key was silently ignored, leaving the
* server to prune its own 90-day default).
*/
class SessionDestructiveOpsSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun pruneRequest_sendsServerFieldNames() {
val encoded = json.encodeToString(PruneRequest(olderThanDays = 7, profile = "work"))
val body = json.parseToJsonElement(encoded).jsonObject
assertEquals("7", body["older_than_days"]!!.jsonPrimitive.content)
assertEquals("work", body["profile"]!!.jsonPrimitive.content)
// The old key must be gone: the backend ignores it.
assertFalse(body.containsKey("days"))
}
@Test
fun pruneRequest_omitsProfileWhenUnnamed() {
val encoded = json.encodeToString(PruneRequest(olderThanDays = 30))
val body = json.parseToJsonElement(encoded).jsonObject
assertEquals("30", body["older_than_days"]!!.jsonPrimitive.content)
assertFalse(body.containsKey("profile"))
}
@Test
fun bulkDeleteRequest_carriesProfile() {
val encoded = json.encodeToString(BulkDeleteRequest(ids = listOf("a", "b"), profile = "work"))
val body = json.parseToJsonElement(encoded).jsonObject
assertEquals("work", body["profile"]!!.jsonPrimitive.content)
assertEquals(listOf("a", "b"), body["ids"]!!.jsonArray.map { it.jsonPrimitive.content })
}
}
@@ -0,0 +1,114 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
import com.m57.hermescontrol.data.model.DebugShareRequest
import com.m57.hermescontrol.data.model.HookCreateRequest
import com.m57.hermescontrol.data.model.HookDeleteRequest
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
/**
* Regression for #1402: `Map<String, Any>` bodies/results have no kotlinx serializer, so Retrofit failed to
* create the converter before any request was sent. These call the real service methods so converter creation
* (which JSON round-trip tests never exercise) is covered.
*/
class HermesApiServiceConverterTest {
private lateinit var server: MockWebServer
private lateinit var api: HermesApiService
@Before
fun setUp() {
server = MockWebServer().also { it.start() }
api =
Retrofit
.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.create(HermesApiService::class.java)
}
@After
fun tearDown() {
server.shutdown()
}
private fun ok(body: String = """{"ok":true}""") =
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body))
@Test
fun catalogInstall_readsResponse() =
runBlocking {
ok("""{"ok":true,"name":"example","background":false}""")
val r = api.installMcpCatalogEntry(McpCatalogInstallRequest(name = "example"))
assertTrue(r.isSuccessful)
assertEquals("example", r.body()?.name)
assertEquals("/api/mcp/catalog/install", server.takeRequest().path)
}
@Test
fun updateMcpServer_sendsEnvBody() =
runBlocking {
ok("""{"name":"s","enabled":true}""")
api.updateMcpServer("s", McpServerUpdateRequest(env = mapOf("K" to "v")))
val req = server.takeRequest()
assertEquals("PUT", req.method)
assertEquals("""{"env":{"K":"v"}}""", req.body.readUtf8())
}
@Test
fun curatorPaused_readsResponse() =
runBlocking {
ok("""{"ok":true,"paused":true}""")
assertTrue(api.setCuratorPaused(mapOf("paused" to true)).isSuccessful)
assertEquals("""{"paused":true}""", server.takeRequest().body.readUtf8())
}
@Test
fun credentialPool_addSendsApiKeyField_removeReadsResponse() =
runBlocking {
ok("""{"ok":true,"provider":"openrouter","count":1}""")
assertTrue(api.addCredentialPoolEntry(CredentialPoolAddRequest("openrouter", "sk", "lbl")).isSuccessful)
assertEquals(
"""{"provider":"openrouter","api_key":"sk","label":"lbl"}""",
server.takeRequest().body.readUtf8(),
)
ok("""{"ok":true,"provider":"openrouter","count":0,"cleaned":[],"hints":[]}""")
assertTrue(api.removeCredentialPoolEntry("openrouter", 1).isSuccessful)
}
@Test
fun hooks_createSendsApprove_deleteSendsBody() =
runBlocking {
ok("""{"ok":true,"event":"e","command":"c","approved":true}""")
assertTrue(api.createHook(HookCreateRequest("e", "c", timeout = 5, approve = true)).isSuccessful)
assertEquals(
"""{"event":"e","command":"c","timeout":5,"approve":true}""",
server.takeRequest().body.readUtf8(),
)
ok()
assertTrue(api.deleteHook(HookDeleteRequest("e", "c")).isSuccessful)
val del = server.takeRequest()
assertEquals("DELETE", del.method)
assertEquals("""{"event":"e","command":"c"}""", del.body.readUtf8())
}
@Test
fun debugShare_sendsRedact() =
runBlocking {
ok("""{"ok":true,"urls":{"report":"u"}}""")
assertTrue(api.runDebugShare(DebugShareRequest(redact = false)).isSuccessful)
assertEquals("""{"redact":false}""", server.takeRequest().body.readUtf8())
}
}
@@ -598,6 +598,27 @@ class HermesApiServiceMockWebServerTest {
assertEquals(404, response.code())
}
@Test
fun getOAuthProviders_toleratesNumericExpiresAt() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{"providers":[{"id":"codex","name":"Codex","flow":"external",
"status":{"logged_in":true,"expires_at":1790766533634,"last_refresh":1790766000000}}]}
""".trimIndent(),
),
)
val response = api.getOAuthProviders()
assertTrue(response.isSuccessful)
val status = response.body()!!.providers[0].status
assertEquals("1790766533634", status.expiresAt)
assertEquals("1790766000000", status.lastRefresh)
}
@Test
fun getOAuthProviders_parsesList() =
runBlocking {
@@ -107,6 +107,24 @@ class AppUpdateCheckerNetworkTest {
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_rcScanAcceptsRepositoriesPaginationFormat() =
runTest {
enqueue(STABLE_BODY)
val nextPage = server.url("/repositories/1270568813/releases?per_page=100&page=2")
enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\""))
enqueue(
"""[{"tag_name":"v2.0.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""",
)
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
assertEquals("v2.0.0-rc.1", info?.tagName)
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
assertEquals("/repositories/1270568813/releases?per_page=100&page=2", server.takeRequest().path)
}
@Test
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() =
runTest {
@@ -3,6 +3,10 @@ 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 com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
@@ -28,7 +32,7 @@ class AccountConnectorRepositoryTest {
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
accountRepo { method, params ->
calls += method to params
operationFixture()
}
@@ -57,19 +61,24 @@ class AccountConnectorRepositoryTest {
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
accountRepo { 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.operationRespond(
ConnectionRespondParams(
owner = ConnectorOwner.account(),
opId = "op-a",
result = ConnectionAnswer(settledBy = "continue"),
),
)
repo.operationWake(
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-a",
),
)
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
assertEquals(
listOf(
WsMethods.CONNECTORS_OPERATION_STATUS,
@@ -86,13 +95,30 @@ class AccountConnectorRepositoryTest {
assertEquals("op-a", params["op_id"])
assertFalse("session_id" in params)
}
assertEquals(
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
"profile" to "work",
),
calls[1].second,
)
assertEquals(
mapOf(
"owner" to mapOf("type" to "account"),
"op_id" to "op-a",
"profile" to "work",
),
calls[2].second,
)
}
@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 ->
accountRepo { method, params ->
calls += method to params
when (method) {
WsMethods.CONNECTORS_CATALOG -> {
@@ -165,7 +191,7 @@ class AccountConnectorRepositoryTest {
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
runTest {
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
val repo = accountRepo { _, _ -> policyFixture() }
val policy = (repo.policy() as AccountConnectorResult.Success).value
assertEquals("effective-revision", policy.revision)
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
@@ -180,7 +206,7 @@ class AccountConnectorRepositoryTest {
runTest {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val repo =
HermesAccountConnectorRepository { method, params ->
accountRepo { method, params ->
calls += method to params
if (method == WsMethods.CONNECTORS_POLICY_GET) {
policyFixture()
@@ -208,10 +234,13 @@ class AccountConnectorRepositoryTest {
@Test fun `malformed authorization does not look like a successful connect`() =
runTest {
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
val repo = accountRepo { _, _ -> mapOf("status" to "initiated") }
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
}
private fun accountRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
HermesAccountConnectorRepository(caller = fakeCaller(handler))
private fun operationFixture() =
mapOf(
"op_id" to "op-a",
@@ -0,0 +1,518 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class ConnectionHealthTest {
private class Fixture(
val scope: CoroutineScope,
var connected: Boolean = true,
var pingDelayMs: Long = 100L,
var pingShouldFail: Boolean = false,
var pendingReplyState: Boolean = false,
var initialTimeMs: Long = 100_000L,
) {
var currentTimeMs: Long = initialTimeMs
val pingCalls = mutableListOf<Long>()
val cancelCalls = AtomicInteger(0)
val logs = mutableListOf<String>()
val debugLogs = mutableListOf<String>()
val health: ConnectionHealth =
ConnectionHealth(
scope = scope,
isConnected = { connected },
pingRequest = { timeoutMs ->
pingCalls.add(timeoutMs)
if (pingShouldFail) {
throw IllegalStateException("Timed out after ${timeoutMs}ms")
}
if (pingDelayMs > 0) {
delay(pingDelayMs)
currentTimeMs += pingDelayMs
}
},
cancelSocket = {
cancelCalls.incrementAndGet()
},
pendingReply = { pendingReplyState },
nowMs = { currentTimeMs },
log = { logs.add(it) },
debugLog = { debugLogs.add(it) },
)
}
@Test
fun pingClockElapsedAndFlowUpdates() =
runTest {
val fixture =
Fixture(
scope = this,
pingDelayMs = 120L,
initialTimeMs = 1_000L,
)
try {
assertEquals(1_000L, fixture.currentTimeMs)
val latency = fixture.health.ping(timeoutMs = 5000L)
assertEquals(120L, latency)
assertEquals(120L, fixture.health.lastLatencyMs.value)
assertEquals(1_120L, fixture.health.lastPongTimestamp)
assertEquals(listOf(5000L), fixture.pingCalls)
} finally {
fixture.health.stop()
}
}
@Test
fun pingFailureLeavesPreviousLatencyUntouched() =
runTest {
val fixture =
Fixture(
scope = this,
pingDelayMs = 50L,
initialTimeMs = 5_000L,
)
try {
val firstLatency = fixture.health.ping(timeoutMs = 5000L)
assertEquals(50L, firstLatency)
assertEquals(50L, fixture.health.lastLatencyMs.value)
fixture.pingShouldFail = true
try {
fixture.health.ping(timeoutMs = 2000L)
fail("Expected ping failure")
} catch (expected: IllegalStateException) {
// Expected
}
// Previous latency state is preserved
assertEquals(50L, fixture.health.lastLatencyMs.value)
} finally {
fixture.health.stop()
}
}
@Test
fun stopResetsLatencyToNull() =
runTest {
val fixture =
Fixture(
scope = this,
pingDelayMs = 40L,
)
try {
fixture.health.ping()
assertEquals(40L, fixture.health.lastLatencyMs.value)
fixture.health.stop()
assertNull(fixture.health.lastLatencyMs.value)
} finally {
fixture.health.stop()
}
}
@Test
fun onInboundFrameUpdatesPongTimestamp() =
runTest {
val fixture =
Fixture(
scope = this,
initialTimeMs = 10_000L,
)
try {
assertEquals(0L, fixture.health.lastPongTimestamp)
fixture.health.onInboundFrame()
assertEquals(10_000L, fixture.health.lastPongTimestamp)
fixture.currentTimeMs = 25_000L
fixture.health.onInboundFrame()
assertEquals(25_000L, fixture.health.lastPongTimestamp)
} finally {
fixture.health.stop()
}
}
@Test
fun isHealthyStrictlyEnforcesStaleThresholdOf30000() =
runTest {
val fixture =
Fixture(
scope = this,
connected = true,
initialTimeMs = 50_000L,
)
try {
fixture.health.onInboundFrame()
assertEquals(50_000L, fixture.health.lastPongTimestamp)
// now - lastPong < 30000 is healthy
fixture.currentTimeMs = 50_000L + 29_999L
assertTrue(fixture.health.isHealthy)
// now - lastPong == 30000 is NOT healthy (< 30000 required)
fixture.currentTimeMs = 50_000L + 30_000L
assertFalse(fixture.health.isHealthy)
// now - lastPong > 30000 is NOT healthy
fixture.currentTimeMs = 50_000L + 30_001L
assertFalse(fixture.health.isHealthy)
} finally {
fixture.health.stop()
}
}
@Test
fun isHealthyDisconnectedGuard() =
runTest {
val fixture =
Fixture(
scope = this,
connected = true,
initialTimeMs = 10_000L,
)
try {
fixture.health.onInboundFrame()
assertTrue(fixture.health.isHealthy)
fixture.connected = false
assertFalse(fixture.health.isHealthy)
} finally {
fixture.health.stop()
}
}
@Test
fun forceHealthCheckForTestWatchdogStrictThresholdCancelsSocket() =
runTest {
val fixture =
Fixture(
scope = this,
connected = true,
initialTimeMs = 100_000L,
)
try {
fixture.health.onInboundFrame()
// Within stale threshold (30000ms), no cancellation
fixture.health.forceHealthCheckForTest(30_000L)
assertEquals(0, fixture.cancelCalls.get())
// Exceeding stale threshold (> 30000ms), cancels socket
fixture.health.forceHealthCheckForTest(30_001L)
assertEquals(1, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun periodicHeartbeatCancelsSocketWhenStaleThresholdExceeded() =
runTest {
val fixture =
Fixture(
scope = this,
connected = true,
pingShouldFail = true,
pingDelayMs = 0L,
initialTimeMs = 10_000L,
)
try {
fixture.health.start()
runCurrent()
fixture.health.onInboundFrame()
// Advance by heartbeat interval (15000ms), still fresh
advanceTimeBy(15_000L)
fixture.currentTimeMs += 15_000L
runCurrent()
assertEquals(0, fixture.cancelCalls.get())
// Advance another heartbeat interval to 30000ms from last inbound frame
advanceTimeBy(15_000L)
fixture.currentTimeMs += 15_000L
runCurrent()
assertEquals(0, fixture.cancelCalls.get())
// Now time elapsed is > 30000ms (e.g. 30001ms)
fixture.currentTimeMs += 1L
advanceTimeBy(15_000L)
fixture.currentTimeMs += 15_000L
runCurrent()
assertEquals(1, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun setLastLatencyForTestSetsFlowDirectly() =
runTest {
val fixture =
Fixture(
scope = this,
)
try {
assertNull(fixture.health.lastLatencyMs.value)
fixture.health.setLastLatencyForTest(75L)
assertEquals(75L, fixture.health.lastLatencyMs.value)
fixture.health.setLastLatencyForTest(null)
assertNull(fixture.health.lastLatencyMs.value)
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnTransportChangeSuccess() =
runTest {
val fixture =
Fixture(
scope = this,
pingDelayMs = 25L,
)
try {
var customActionCalled = false
fixture.health.probeLivenessOnTransportChange(
timeoutMs = 5000L,
onFailureAction = { customActionCalled = true },
)
runCurrent()
assertEquals(listOf(5000L), fixture.pingCalls)
assertFalse(customActionCalled)
assertEquals(0, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnTransportChangeFailureInvokesCustomAction() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
)
try {
var customActionCalled = false
fixture.health.probeLivenessOnTransportChange(
timeoutMs = 4000L,
onFailureAction = { customActionCalled = true },
)
runCurrent()
assertEquals(listOf(4000L), fixture.pingCalls)
assertTrue(customActionCalled)
assertEquals(0, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnTransportChangeFailureInvokesDefaultCancelSocket() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
)
try {
fixture.health.probeLivenessOnTransportChange(
timeoutMs = 5000L,
)
runCurrent()
assertEquals(listOf(5000L), fixture.pingCalls)
assertEquals(1, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnWakeIdleCancelsImmediatelyOnFailure() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
pendingReplyState = false,
)
try {
fixture.health.probeLivenessOnWake(deferredOnce = false)
runCurrent()
assertEquals(listOf(5000L), fixture.pingCalls)
assertEquals(1, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnWakePendingReplyFirstFailureDefers3000MsAndRetriesOnce() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
pendingReplyState = true,
)
try {
fixture.health.probeLivenessOnWake(deferredOnce = false)
runCurrent()
// First probe fails, but because pendingReply=true, it does not cancel immediately.
// It schedules a deferred retry after 3000ms.
assertEquals(1, fixture.pingCalls.size)
assertEquals(0, fixture.cancelCalls.get())
// Advance before 3000ms
advanceTimeBy(2999L)
runCurrent()
assertEquals(1, fixture.pingCalls.size)
assertEquals(0, fixture.cancelCalls.get())
// Advance to 3000ms - second ping is executed
advanceTimeBy(1L)
runCurrent()
assertEquals(2, fixture.pingCalls.size)
// Second probe fails, now it cancels
assertEquals(1, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnWakePendingReplySecondProbeSucceedsLeavesSocketOpen() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
pendingReplyState = true,
pingDelayMs = 10L,
)
try {
fixture.health.probeLivenessOnWake(deferredOnce = false)
runCurrent()
// First attempt failed, pending deferred retry in 3000ms
assertEquals(1, fixture.pingCalls.size)
assertEquals(0, fixture.cancelCalls.get())
// Before retry fires, recovery occurs: pings now succeed
fixture.pingShouldFail = false
advanceTimeBy(3000L)
runCurrent()
assertEquals(2, fixture.pingCalls.size)
assertEquals(0, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun cancelForegroundProbeCancelsPendingProbe() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
pendingReplyState = true,
)
try {
fixture.health.probeLivenessOnWake(deferredOnce = false)
runCurrent()
assertEquals(1, fixture.pingCalls.size)
assertEquals(0, fixture.cancelCalls.get())
// Cancel the scheduled retry
fixture.health.cancelForegroundProbe()
advanceTimeBy(5000L)
runCurrent()
// No second probe should have been made, no cancellation
assertEquals(1, fixture.pingCalls.size)
assertEquals(0, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnTransportChangeSuccessLogsAtDebugLevelWithoutWarning() =
runTest {
val fixture =
Fixture(
scope = this,
pingDelayMs = 25L,
)
try {
fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L)
runCurrent()
advanceTimeBy(25L)
runCurrent()
assertEquals(listOf(5000L), fixture.pingCalls)
assertEquals(
listOf(
"Network transport changed — probing WebSocket liveness",
"WebSocket liveness probe succeeded on new transport",
),
fixture.debugLogs,
)
assertTrue(fixture.logs.isEmpty())
assertEquals(0, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
@Test
fun probeLivenessOnTransportChangeFailureLogsDebugAndWarningAndCancelsSocket() =
runTest {
val fixture =
Fixture(
scope = this,
pingShouldFail = true,
)
try {
fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L)
runCurrent()
assertEquals(listOf(5000L), fixture.pingCalls)
assertEquals(
listOf("Network transport changed — probing WebSocket liveness"),
fixture.debugLogs,
)
assertEquals(
listOf("Transport change liveness probe failed — cancelling socket"),
fixture.logs,
)
assertEquals(1, fixture.cancelCalls.get())
} finally {
fixture.health.stop()
}
}
}
@@ -34,11 +34,12 @@ class ConnectorRepositoryTest {
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
},
caller =
fakeCaller { method, params ->
recordedMethod = method
recordedParams = params
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
},
)
val result = repo.listConnectors("sess-abc-123")
@@ -61,17 +62,18 @@ class ConnectorRepositoryTest {
val repo =
HermesConnectorRepository(
rpcRequest = { method, params ->
recordedMethod = method
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "initiated"),
),
"summary" to mapOf("total" to 1, "initiated" to 1),
)
},
caller =
fakeCaller { method, params ->
recordedMethod = method
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "linear", "status" to "initiated"),
),
"summary" to mapOf("total" to 1, "initiated" to 1),
)
},
)
val result =
@@ -103,16 +105,17 @@ class ConnectorRepositoryTest {
val repo =
HermesConnectorRepository(
rpcRequest = { _, params ->
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "slack", "status" to "active"),
),
"summary" to mapOf("total" to 1, "active" to 1),
)
},
caller =
fakeCaller { _, params ->
recordedParams = params
mapOf(
"results" to
listOf(
mapOf("connector" to "slack", "status" to "active"),
),
"summary" to mapOf("total" to 1, "active" to 1),
)
},
)
repo.connect(sessionId = "sess-789", connectors = listOf("slack"))
@@ -125,10 +128,11 @@ class ConnectorRepositoryTest {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
caller =
fakeCaller { _, _ ->
rpcCalled = true
null
},
)
val r1 = repo.listConnectors("")
@@ -147,10 +151,11 @@ class ConnectorRepositoryTest {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
caller =
fakeCaller { _, _ ->
rpcCalled = true
null
},
)
// Blank session_id
@@ -172,10 +177,11 @@ class ConnectorRepositoryTest {
var rpcCalled = false
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
rpcCalled = true
null
},
caller =
fakeCaller { _, _ ->
rpcCalled = true
null
},
)
// Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols
@@ -210,9 +216,10 @@ class ConnectorRepositoryTest {
runBlocking {
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw CancellationException("coroutine cancelled")
},
caller =
fakeCaller { _, _ ->
throw CancellationException("coroutine cancelled")
},
)
try {
@@ -236,13 +243,14 @@ class ConnectorRepositoryTest {
// 4031 CONNECTORS_UNAVAILABLE
val repo4031 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Connectors unavailable",
code = 4031,
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
)
},
caller =
fakeCaller { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Connectors unavailable",
code = 4031,
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
)
},
)
val r4031 = repo4031.listConnectors("sess-1")
assertTrue(r4031 is ConnectorListResult.Error)
@@ -251,13 +259,14 @@ class ConnectorRepositoryTest {
// 4001 NOT_OWNER
val repo4001 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Session not owned",
code = 4001,
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
)
},
caller =
fakeCaller { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Session not owned",
code = 4001,
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
)
},
)
val r4001 = repo4001.connect("sess-1", listOf("linear"))
assertTrue(r4001 is ConnectorConnectResult.Error)
@@ -266,13 +275,14 @@ class ConnectorRepositoryTest {
// 5033 UNSUPPORTED_RUNTIME
val repo5033 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Compute host required",
code = 5033,
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
)
},
caller =
fakeCaller { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Compute host required",
code = 5033,
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
)
},
)
val r5033 = repo5033.connect("sess-1", listOf("linear"))
assertTrue(r5033 is ConnectorConnectResult.Error)
@@ -281,13 +291,14 @@ class ConnectorRepositoryTest {
// 5034 INVALID_CONNECTOR_RESPONSE
val repo5034 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Invalid connector response",
code = 5034,
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
)
},
caller =
fakeCaller { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Invalid connector response",
code = 5034,
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
)
},
)
val r5034 = repo5034.connect("sess-1", listOf("linear"))
assertTrue(r5034 is ConnectorConnectResult.Error)
@@ -296,13 +307,14 @@ class ConnectorRepositoryTest {
// -32601 Unsupported backend
val repo32601 =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Method not found",
code = -32601,
data = null,
)
},
caller =
fakeCaller { _, _ ->
throw HermesWsClient.HermesRpcException(
message = "Method not found",
code = -32601,
data = null,
)
},
)
val r32601 = repo32601.listConnectors("sess-1")
assertTrue(r32601 is ConnectorListResult.Error)
@@ -314,9 +326,10 @@ class ConnectorRepositoryTest {
runBlocking {
val repo =
HermesConnectorRepository(
rpcRequest = { _, _ ->
throw java.io.IOException("Connection reset by peer")
},
caller =
fakeCaller { _, _ ->
throw java.io.IOException("Connection reset by peer")
},
)
val rList = repo.listConnectors("sess-1")
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.MessageReaction
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
@@ -928,6 +929,52 @@ class EventParserTest {
)
}
@Test
fun testParseMessageReaction_returnsMessageReactionUpdated() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params =
mapOf(
"type" to "message.reaction",
"session_id" to "s1",
"payload" to
mapOf(
"row_id" to 42,
"role" to "user",
"reactions" to
listOf(
mapOf("emoji" to "\uD83D\uDE02", "author" to "agent", "at" to 1.0),
mapOf("emoji" to "", "author" to "user"),
),
),
),
)
val event = EventParser.parse(response, "") as WsEvent.MessageReactionUpdated
assertEquals(42L, event.rowId)
assertEquals("user", event.role)
assertEquals("s1", event.sessionId)
assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), event.reactions)
}
@Test
fun testParseMessageReaction_withoutRowId_isUnknown() {
val response =
createJsonRpcResponse(
jsonrpc = "2.0",
id = null,
result = null,
error = null,
method = "event",
params = mapOf("type" to "message.reaction", "payload" to mapOf("reactions" to emptyList<Any>())),
)
assertTrue(EventParser.parse(response, "") is WsEvent.Unknown)
}
@Test
fun testParseUnknownType_returnsUnknownEvent() {
val response =
@@ -0,0 +1,22 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
/**
* Test double for [TypedRpcCaller]: encodes the typed params exactly as the wire would, hands the
* resulting map to [handler] together with the method name, and returns its value as a JsonElement.
*/
internal fun fakeCaller(handler: suspend (String, Map<String, Any>) -> Any?): TypedRpcCaller =
object : TypedRpcCaller {
@Suppress("UNCHECKED_CAST")
override suspend fun <P, R> call(
method: RpcMethod<P, R>,
params: P,
): R {
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
val map = (encoded.toAny() as? Map<String, Any>).orEmpty()
return ConnectorParser.toJsonElement(handler(method.name, map)) as R
}
}
@@ -0,0 +1,170 @@
package com.m57.hermescontrol.data.ws
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Vendored snapshot of the backend's published gateway contract
* (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only.
* Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`.
*/
object GatewayContract {
private val root: JsonObject by lazy {
val stream =
requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) {
"gateway-contract/openrpc.json missing from test resources"
}
Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject
}
private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject
private fun names(key: String): Set<String> =
(root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet()
val methods: Set<String> by lazy { names("methods") }
/** Server→client request methods (`x-server-requests`). */
val serverRequests: Set<String> by lazy { names("x-server-requests") }
private fun paramsSchema(method: String): JsonObject {
val entry =
(root["methods"] as JsonArray)
.map { it.jsonObject }
.first { it["name"]!!.jsonPrimitive.content == method }
val ref =
entry["params"]!!
.jsonArray
.first()
.jsonObject["schema"]!!
.jsonObject["\$ref"]!!
.jsonPrimitive.content
return schemas[ref.substringAfterLast('/')]!!.jsonObject
}
/**
* Keys the backend would reject for [method]: unknown keys when the params
* schema forbids extras, plus required keys that are absent. Empty means OK.
* Keys only; value types are not checked.
*/
fun paramKeyProblems(
method: String,
keys: Set<String>,
): List<String> {
if (method !in methods) return listOf("$method is not a contract method")
val schema = paramsSchema(method)
val properties = schema["properties"]?.jsonObject?.keys.orEmpty()
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()
val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false
val problems = mutableListOf<String>()
if (strict) {
(keys - properties).sorted().forEach {
problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})"
}
}
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
return problems
}
/**
* Verifies that a Kotlinx [descriptor] for [method]'s params matches the published contract.
* Empty list means OK.
*/
fun paramsDescriptorProblems(
method: String,
descriptor: SerialDescriptor,
): List<String> {
if (method !in methods) return listOf("$method is not a contract method")
val schema = paramsSchema(method)
val propertiesObj = schema["properties"]?.jsonObject ?: JsonObject(emptyMap())
val properties = propertiesObj.keys
val required =
schema["required"]
?.jsonArray
?.map { it.jsonPrimitive.content }
?.toSet()
.orEmpty()
val problems = mutableListOf<String>()
val elementNames = (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()
for (i in 0 until descriptor.elementsCount) {
val name = descriptor.getElementName(i)
if (name !in properties) {
problems +=
"$method: unknown param '$name' (backend forbids extra keys; allowed: ${properties.sorted()})"
}
}
// No exemptions: decorate() skips injection when no profile is active, so a required `profile`
// must be declared like any other required param.
val missingRequired = required.filter { it !in elementNames }
for (missing in missingRequired) {
problems += "$method: missing required param '$missing'"
}
for (i in 0 until descriptor.elementsCount) {
val name = descriptor.getElementName(i)
if (name in required) {
if (descriptor.isElementOptional(i)) {
problems += "$method: required param '$name' must not be optional"
}
val propSchema = propertiesObj[name]?.jsonObject
val allowsNull = propSchema?.let { propertyAllowsNull(it) } ?: false
if (descriptor.getElementDescriptor(i).isNullable && !allowsNull) {
problems += "$method: required param '$name' must not be nullable unless schema allows null"
}
}
}
return problems
}
private fun propertyAllowsNull(propertySchema: JsonObject): Boolean {
val typeElement = propertySchema["type"]
if (typeElement != null) {
if (typeElement is JsonArray && typeElement.any { it.jsonPrimitive.content == "null" }) {
return true
}
if (typeElement is JsonPrimitive && typeElement.content == "null") {
return true
}
}
val anyOf = propertySchema["anyOf"]?.jsonArray
if (anyOf != null && anyOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) {
return true
}
val oneOf = propertySchema["oneOf"]?.jsonArray
if (oneOf != null && oneOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) {
return true
}
return false
}
}
/**
* WsMethods constants the backend contract does not list. Every entry needs a
* reason; the test fails if an entry appears in the contract or is no longer a
* WsMethods constant, so stale exceptions get removed.
*/
object GatewayContractAllowlist {
private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)"
val entries: Map<String, String> =
mapOf(
"gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method",
"clarify.respond" to LEGACY,
"sudo.respond" to LEGACY,
"secret.respond" to LEGACY,
"vault.unlock.respond" to LEGACY,
"vault.code.respond" to LEGACY,
"vault.save_login.respond" to LEGACY,
)
}
@@ -0,0 +1,146 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* Sends the client-owned RPCs through the real [HermesWsClient] and checks the
* exact frame on the wire against the vendored gateway contract (#1374).
*/
class GatewayContractFramesTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
}
@After
fun tearDown() {
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
private fun captureFrame(
method: String,
beforeConnect: () -> Unit = {},
trigger: () -> Unit = {},
): JsonObject {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frame = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (frame.get() == null && name == method) {
frame.set(json)
captured.countDown()
}
}
},
),
)
beforeConnect()
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
assertTrue(opened.await(5, TimeUnit.SECONDS))
trigger()
assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS))
return requireNotNull(frame.get())
}
private fun assertMatchesContract(frame: JsonObject) {
val method = (frame["method"] as JsonPrimitive).content
val params = frame["params"] as JsonObject
val problems = GatewayContract.paramKeyProblems(method, params.keys)
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
@Test
fun promptSubmitFrameMatchesContract() {
assertMatchesContract(
captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) },
)
}
@Test
fun eventsSinceReplayFrameMatchesContract() {
assertMatchesContract(
captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }),
)
}
}
@@ -0,0 +1,113 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.reflect.Modifier
/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */
class GatewayContractTest {
private val wsMethodConstants: Map<String, String> =
WsMethods::class.java.declaredFields
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
.associate { it.name to it.get(null) as String }
@Test
fun everyWsMethodIsInTheContractOrAllowlisted() {
val unknown =
wsMethodConstants.filter { (_, method) ->
method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries
}
assertTrue(
"WsMethods constants missing from the gateway contract (backend removed or renamed them?): " +
unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } +
". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.",
unknown.isEmpty(),
)
}
@Test
fun profileScopedMethodsAreContractMethods() {
val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods
assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty())
}
@Test
fun allowlistEntriesHaveReasonsAndAreNotStale() {
val sent = wsMethodConstants.values.toSet()
GatewayContractAllowlist.entries.forEach { (method, reason) ->
assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank())
assertTrue(
"Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist",
method !in GatewayContract.methods,
)
assertTrue(
"Allowlist entry '$method' is no longer a WsMethods constant; remove it",
method in sent,
)
}
}
@Test
fun handledServerRequestsExistInTheContract() {
val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests
assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty())
// Informational: unsupported requests are already answered with -32601.
val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted()
println("Contract server requests the app does not handle: $unhandled")
}
@Test
fun paramKeyCheckRejectsTheRegressionsItExistsFor() {
// #1118: the backend forbids extra keys, so since_seq broke replay with error 4000.
val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq"))
assertEquals(1, problems.count { "since_seq" in it })
assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty())
assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty())
}
@Test
fun literalParamKeysAtCallSitesMatchTheContract() {
CALL_SITE_PARAM_KEYS.forEach { (method, keys) ->
val problems = GatewayContract.paramKeyProblems(method, keys)
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
}
@Test
fun typedParamsMatchTheContract() {
val methods = RpcMethods.all
assertTrue("RpcMethods.all must not be empty", methods.isNotEmpty())
val names = methods.map { it.name }
assertEquals("RpcMethods.all names must be unique", names.distinct(), names)
val problems =
methods.flatMap { method ->
GatewayContract.paramsDescriptorProblems(method.name, method.params.descriptor)
}
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
private companion object {
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
val APP_HANDLED_SERVER_REQUESTS =
setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt")
/**
* Union of literal param keys at production call sites (the two client-owned
* ones, prompt.submit and session.events.since, are checked against real frames
* in [GatewayContractFramesTest]). Update together with the call site.
*/
val CALL_SITE_PARAM_KEYS: Map<String, Set<String>> =
mapOf(
// ChatViewModel, GroupChatViewModel
"session.create" to setOf("profile", "title", "source", "hidden"),
// ChatViewModel.resumeSession, NotificationReplyReceiver
"session.resume" to setOf("session_id", "omit_messages"),
// ChatViewModel, GroupChatViewModel
"session.interrupt" to setOf("session_id"),
)
}
}
@@ -0,0 +1,237 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientApprovalsTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeoutOrNull(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedSendApprovalRespondPutsExactParamsOnWire() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "approval.respond") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.send(
method = RpcMethods.APPROVAL_RESPOND,
params =
ApprovalRespondParams(
sessionId = "sess-appr-wire-1",
choice = "deny",
all = false,
requestId = "req-appr-wire-42",
),
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("approval.respond", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put("session_id", "sess-appr-wire-1")
put("choice", "deny")
put("all", false)
put("request_id", "req-appr-wire-42")
}
assertEquals(expectedParams, params)
assertEquals(false, (params?.get("all") as? JsonPrimitive)?.booleanOrNull)
}
@Test
fun typedCallSessionActiveListReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("active_count", 2)
put(
"sessions",
buildJsonObject {
put("primary", "sess-alpha")
put("secondary", "sess-beta")
},
)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.active_list") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_ACTIVE_LIST,
SessionActiveListParams(),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("session.active_list", (frame["method"] as? JsonPrimitive)?.content)
// An empty params object is omitted on wire or empty
val params = frame["params"] as? JsonObject
assertTrue(
"session.active_list with null currentSessionId carries no params",
params == null || params.isEmpty(),
)
assertEquals(serverResultJson, result)
}
}
@@ -0,0 +1,431 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.EmptyParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams
import com.m57.hermescontrol.data.ws.contract.VaultLockParams
import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientBillingVaultTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeoutOrNull(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedCallSubscriptionChangeEncodesOnlyNonNullFieldsAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("subscription_id", "sub-12345")
put("scheduled_cancellation", false)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "subscription.change") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SUBSCRIPTION_CHANGE,
SubscriptionChangeParams(
subscriptionTypeId = null,
cancel = false,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("subscription.change", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put("cancel", false)
}
assertEquals(expectedParams, params)
assertEquals(false, (params?.get("cancel") as? JsonPrimitive)?.booleanOrNull)
assertFalse(params?.containsKey("subscription_type_id") ?: true)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallVaultUnlockPutsNameAndPasswordOnWireAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("unlocked", true)
put("expires_in", 3600)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "vault.unlock") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.VAULT_UNLOCK,
VaultUnlockParams(
name = "op-work",
password = "super-secret-pass",
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("vault.unlock", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put("name", "op-work")
put("password", "super-secret-pass")
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallVaultLockWithNamePutsNameOnWireAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("locked", true)
put("vault", "op-work")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "vault.lock") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.VAULT_LOCK,
VaultLockParams(name = "op-work"),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put("name", "op-work")
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallVaultLockWithoutNameLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("locked_all", true)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "vault.lock") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.VAULT_LOCK,
VaultLockParams(name = null),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertTrue(
"vault.lock with null name carries no params",
params == null || params.isEmpty(),
)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallEmptyParamsMethodLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "active")
put("tier", "standard")
put("valid_until", "2026-12-31T23:59:59Z")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "subscription.state") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SUBSCRIPTION_STATE,
EmptyParams,
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("subscription.state", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertTrue(
"subscription.state with EmptyParams carries no params",
params == null || params.isEmpty(),
)
assertEquals(serverResultJson, result)
}
}
@@ -0,0 +1,532 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
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.flow.first
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
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.Before
import org.junit.Test
import java.util.Collections
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* Characterization test suite capturing existing baseline behaviors and edge cases
* of [HermesWsClient] before extracting components in issue #1376.
*/
class HermesWsClientCharacterizationTest {
private lateinit var mockWebServer: MockWebServer
private val serverSockets = Collections.synchronizedList(mutableListOf<WebSocket>())
private lateinit var serverClosed: CountDownLatch
@Before
fun setUp() {
serverSockets.clear()
serverClosed = CountDownLatch(1)
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.connectedForTest.set(false)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
HermesWsClient.messageQueueForTest.clear()
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
HermesWsClient.acceptQueuedMessagesForTest.set(true)
HermesWsClient.clearCredentialWarning()
HermesWsClient.clearSeqWatermarks()
HermesWsClient.setLastLatencyForTest(null)
}
@After
fun tearDown() {
HermesWsClient.rejectAllPending()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(3000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
try {
if (serverSockets.isNotEmpty()) {
assertTrue("Server endpoint must finish closing", serverClosed.await(3, TimeUnit.SECONDS))
}
mockWebServer.shutdown()
} finally {
unmockkAll()
}
}
private fun connectClientToServer(onServerOpen: ((WebSocket) -> Unit)? = null): WebSocket {
val serverOpenLatch = CountDownLatch(1)
var serverWs: WebSocket? = null
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, reason)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
serverClosed.countDown()
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: okhttp3.Response?,
) {
serverClosed.countDown()
}
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverSockets.add(webSocket)
serverWs = webSocket
onServerOpen?.invoke(webSocket)
serverOpenLatch.countDown()
}
},
),
)
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS))
return requireNotNull(serverWs)
}
@Test
fun credentialWarning_updatedByGatewayReadyAndSessionInfo_retainedOnBlank_clearedExplicitly() {
assertNull(HermesWsClient.credentialWarning.value)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectedForTest.set(true)
val dummySocket =
mockk<WebSocket>(relaxed = true) {
every { send(any<String>()) } returns true
}
HermesWsClient.webSocketForTest = dummySocket
val generation = HermesWsClient.connectionGenerationForTest.get()
val listener = HermesWsClient.createListenerForTest(generation)
// 1. GatewayReady with non-blank credential_warning updates flow
val gatewayReadyFrame =
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":"Warning: Auth key expired"}}}"""
listener.onMessage(dummySocket, gatewayReadyFrame)
runBlocking {
withTimeout(3000) {
HermesWsClient.credentialWarning.first { it == "Warning: Auth key expired" }
}
}
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
// 2. Blank or absent credential_warning does NOT clear the existing value.
// Deliver follow-up gateway.ready (absent) after blank and check warning is retained,
// then use session.info barrier to prove processing pipeline is drained.
val blankFrame =
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":""}}}"""
listener.onMessage(dummySocket, blankFrame)
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
val absentFrame =
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"version":"1.0"}}}"""
listener.onMessage(dummySocket, absentFrame)
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
// Barrier: a distinct non-blank session.info proves all preceding frames were processed in order
val barrierFrame =
"""{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s-barrier","payload":{"credential_warning":"Barrier reached"}}}"""
listener.onMessage(dummySocket, barrierFrame)
runBlocking {
withTimeout(3000) {
HermesWsClient.credentialWarning.first { it == "Barrier reached" }
}
}
assertEquals("Barrier reached", HermesWsClient.credentialWarning.value)
// 3. SessionInfo with non-blank updates warning
val sessionInfoFrame =
"""{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s1","payload":{"credential_warning":"Session warning"}}}"""
listener.onMessage(dummySocket, sessionInfoFrame)
runBlocking {
withTimeout(3000) {
HermesWsClient.credentialWarning.first { it == "Session warning" }
}
}
assertEquals("Session warning", HermesWsClient.credentialWarning.value)
// 4. Explicit clear resets to null
HermesWsClient.clearCredentialWarning()
assertNull(HermesWsClient.credentialWarning.value)
}
@Test
fun disconnect_resetsLastLatencyMsToNull() {
HermesWsClient.setLastLatencyForTest(42L)
assertEquals(42L, HermesWsClient.lastLatencyMs.value)
HermesWsClient.disconnect()
assertNull(HermesWsClient.lastLatencyMs.value)
}
@Test
fun rpcTimeout_emitsHermesRpcExceptionCodeMinusOneExactMessage() {
val serverWs = connectClientToServer()
assertNotNull(serverWs)
val shortTimeoutMs = 150L
val deferred = HermesWsClient.request("custom.test_method", emptyMap(), timeoutMs = shortTimeoutMs)
try {
runBlocking {
withTimeout(3000) {
deferred.await()
}
}
fail("Expected deferred to fail with timeout exception")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals(-1, e.code)
assertEquals("Request timed out: custom.test_method", e.message)
}
}
@Test
fun rejectAllPending_failsRequestsWithCustomException_cancelsTimeout_clearsPending() {
connectClientToServer()
val deferred = HermesWsClient.request("custom.hold", emptyMap(), timeoutMs = 10_000L)
val pendingIds = HermesWsClient.pendingCallIdsForTest()
assertEquals(1, pendingIds.size)
val reqId = pendingIds.first()
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId)
assertNotNull("Timeout job should be scheduled", timeoutJob)
assertTrue("Timeout job should be active", timeoutJob?.isActive == true)
val customException = HermesWsClient.HermesRpcException("Custom forced rejection", code = -99)
HermesWsClient.rejectAllPending(customException)
assertTrue(
"Pending map should be empty after rejectAllPending",
HermesWsClient.pendingCallIdsForTest().isEmpty(),
)
assertTrue("Timeout job should be cancelled", timeoutJob?.isCancelled == true)
assertTrue("Deferred should be completed exceptionally", deferred.isCompleted)
try {
runBlocking { deferred.await() }
fail("Expected custom exception")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals(-99, e.code)
assertEquals("Custom forced rejection", e.message)
}
}
@Test
fun reconnectForNetworkChange_rejectsPendingCallsImmediately() {
connectClientToServer()
val deferred = HermesWsClient.request("custom.in_flight", emptyMap(), timeoutMs = 30_000L)
assertEquals(1, HermesWsClient.pendingCallIdsForTest().size)
// Network change triggers immediate rejectAllPending
HermesWsClient.reconnectForNetworkChange(networkAvailable = false)
assertTrue("Pending map should be empty after network change", HermesWsClient.pendingCallIdsForTest().isEmpty())
assertTrue("Deferred must be completed", deferred.isCompleted)
try {
runBlocking { deferred.await() }
fail("Expected pending call to be rejected on network change")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals("Connection lost — request cancelled", e.message)
}
}
@Test
fun disconnectAndListenerClosed_doNotRejectAllPending_deferredRemainsPendingUntilTimeoutOrReject() {
val serverWs = connectClientToServer()
val shortTimeoutMs = 300L
val deferred = HermesWsClient.request("custom.deferred_call", emptyMap(), timeoutMs = shortTimeoutMs)
val reqId = HermesWsClient.pendingCallIdsForTest().first()
// Drive onClosed on the CURRENT active generation before disconnect.
// In onClosed: isCurrent() is true (intentionalClose is still false, generation matches).
// This exercises the active listener onClosed branch.
val activeGeneration = HermesWsClient.connectionGenerationForTest.get()
val currentListener = HermesWsClient.createListenerForTest(activeGeneration)
val activeClientSocket = HermesWsClient.webSocketForTest ?: serverWs
currentListener.onClosed(activeClientSocket, 1000, "Normal closure")
activeClientSocket.cancel()
// Deferred remains pending after active onClosed
assertFalse("Deferred should still remain pending after onClosed", deferred.isCompleted)
assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId))
// Calling disconnect() does NOT invoke rejectAllPending() either
HermesWsClient.disconnect()
assertFalse("Deferred should NOT be completed immediately by disconnect() alone", deferred.isCompleted)
assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId))
// Eventually, the per-request timeout triggers and resolves it
try {
runBlocking {
withTimeout(2000) {
deferred.await()
}
}
fail("Deferred should fail with timeout")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals(-1, e.code)
assertEquals("Request timed out: custom.deferred_call", e.message)
}
}
@Test
fun combinedExternalAndBackgroundLeases_releaseOneKeepsOther_releaseBothDisconnectsWhenBackgrounded() {
connectClientToServer()
assertTrue(HermesWsClient.isConnected)
// Acquire both leases
HermesWsClient.acquireExternalActivityConnectionLease()
HermesWsClient.acquireBackgroundConnectionLease()
assertTrue(HermesWsClient.hasBackgroundConnectionLease())
// Move to background - should NOT disconnect because leases are active
HermesWsClient.setAppForeground(false)
assertTrue("Should stay connected while leases are active", HermesWsClient.isConnected)
// Release external lease - background lease still holds connection open
HermesWsClient.releaseExternalActivityConnectionLease()
assertTrue("Should stay connected with background lease active", HermesWsClient.isConnected)
// Release background lease - both leases released, app backgrounded, idle -> disconnects
HermesWsClient.releaseBackgroundConnectionLease()
assertFalse("Should disconnect when all leases released in background", HermesWsClient.isConnected)
}
@Test
fun delayedReplayAndLiveRace_incomingSeqBufferedBeforeReplayResponseAndDeduped() {
val sessionId = "session-characterization-race"
val serverOpenLatch = CountDownLatch(1)
val replayReceivedLatch = CountDownLatch(1)
val allowReplayResponseLatch = CountDownLatch(1)
val capturedReplayId = AtomicReference<String>()
var serverSocketRef: WebSocket? = null
// Enqueue MockResponse with WebSocketListener on MockWebServer
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onClosing(
webSocket: WebSocket,
code: Int,
reason: String,
) {
webSocket.close(code, reason)
}
override fun onClosed(
webSocket: WebSocket,
code: Int,
reason: String,
) {
serverClosed.countDown()
}
override fun onFailure(
webSocket: WebSocket,
t: Throwable,
response: okhttp3.Response?,
) {
serverClosed.countDown()
}
override fun onOpen(
webSocket: WebSocket,
response: okhttp3.Response,
) {
serverSockets.add(webSocket)
serverSocketRef = webSocket
serverOpenLatch.countDown()
}
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) {
val request = OkHttpProvider.json.parseToJsonElement(text) as JsonObject
val id = (request["id"] as JsonPrimitive).content
capturedReplayId.set(id)
replayReceivedLatch.countDown()
if (allowReplayResponseLatch.await(5, TimeUnit.SECONDS)) {
val rpcId = requireNotNull(capturedReplayId.get())
val idJson = JsonPrimitive(rpcId).toString()
val replayResponse =
"""{"jsonrpc":"2.0","id":$idJson,"result":{"epoch":"epoch-1","latest_seq":12,"events":[{"type":"message.complete","session_id":"$sessionId","seq":11,"payload":{"text":"Replayed message 11"}},{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Replayed message 12"}}]}}"""
webSocket.send(replayResponse)
}
}
}
},
),
)
// Set watermark at seq 10 and epoch-1 prior to connect
HermesWsClient.setSeqWatermark(sessionId, 10)
HermesWsClient.setReplayEpochForTest("epoch-1")
// Prepare subscription with onSubscription latch before stimulus
val collectedEvents = Collections.synchronizedList(mutableListOf<WsEvent>())
val subscriptionLatch = CountDownLatch(1)
val collectLatch = CountDownLatch(2)
val liveProcessedLatch = CountDownLatch(1)
val collectorJob =
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
HermesWsClient.events
.onSubscription {
subscriptionLatch.countDown()
}.collect { event ->
if (event is WsEvent.SessionInfo && event.data?.get("barrier") == "live-processed") {
liveProcessedLatch.countDown()
}
if (event is WsEvent.MessageComplete && event.sessionId == sessionId) {
collectedEvents.add(event)
collectLatch.countDown()
}
}
}
try {
assertTrue("Collector subscription should be established", subscriptionLatch.await(3, TimeUnit.SECONDS))
// Connect client - real onOpen automatically arms replay
HermesWsClient.connect()
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
}
}
assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS))
val serverSocket = requireNotNull(serverSocketRef)
// Wait until the server receives session.events.since request
assertTrue("Server should receive replay request", replayReceivedLatch.await(5, TimeUnit.SECONDS))
assertTrue("Replay should be in flight", HermesWsClient.isReplayInFlight())
// 1. Live frame arrived while replay is in flight (seq 12)
val liveFrame =
"""{"jsonrpc":"2.0","method":"event","params":{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Live message 12"}}}"""
serverSocket.send(liveFrame)
// A following unsequenced frame proves the live notification reached the client
// before the replay response is released; unchanged watermark alone is not a barrier.
serverSocket.send(
"""{"method":"event","params":{"type":"session.info","session_id":"$sessionId","payload":{"barrier":"live-processed"}}}""",
)
assertTrue(
"Live frame must be processed before replay completes",
liveProcessedLatch.await(3, TimeUnit.SECONDS),
)
assertEquals(10, HermesWsClient.getSeqWatermarks()[sessionId])
assertEquals("Live frame should be held in replayHold while replay is in flight", 0, collectedEvents.size)
// 2. Unblock replay response from server
allowReplayResponseLatch.countDown()
// Wait for replay to deliver replayed events and drain held live events
assertTrue("Should collect replayed and unheld events", collectLatch.await(5, TimeUnit.SECONDS))
// Watermark should advance to 12
assertEquals(12, HermesWsClient.getSeqWatermarks()[sessionId])
// Deduplication: held live frame seq 12 deduped against replay seq 12
assertEquals(2, collectedEvents.size)
assertEquals("Replayed message 11", (collectedEvents[0] as WsEvent.MessageComplete).text)
assertEquals("Replayed message 12", (collectedEvents[1] as WsEvent.MessageComplete).text)
} finally {
allowReplayResponseLatch.countDown()
collectorJob.cancel()
}
}
}
@@ -0,0 +1,562 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.RpcMethods
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
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
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientConnectionOperationTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeoutOrNull(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedCallConnectionRespondWithApprovedEnvAndAccountOwnerEncodesFrameAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("settled", true)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connection.respond") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTION_RESPOND,
ConnectionRespondParams(
owner = ConnectorOwner.account(),
opId = "op-approved-123",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "approved",
detail = "OAuth granted",
env = mapOf("TOKEN" to "dummy_secret_token_abc"),
),
),
settledBy = null,
),
profile = "prod",
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-approved-123")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "approved")
put("detail", "OAuth granted")
put(
"env",
buildJsonObject {
put("TOKEN", "dummy_secret_token_abc")
},
)
},
)
},
)
},
)
put("profile", "prod")
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectionRespondWithSkippedOmittedEnvAndSessionOwnerEncodesFrameAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("settled", false)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connection.respond") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTION_RESPOND,
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-skip-456"),
opId = "op-skip-456",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "slack",
status = "skipped",
detail = null,
env = null,
),
),
settledBy = null,
),
profile = null,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-skip-456")
},
)
put("op_id", "op-skip-456")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "slack")
put("status", "skipped")
},
)
},
)
},
)
}
assertEquals(expectedParams, params)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectionRespondWithContinueAndOmittedTargetsEncodesFrameAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("status", "ok")
put("settled", true)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connection.respond") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTION_RESPOND,
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-continue-789"),
opId = "op-continue-789",
result =
ConnectionAnswer(
targets = null,
settledBy = "continue",
),
profile = null,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-continue-789")
},
)
put("op_id", "op-continue-789")
put(
"result",
buildJsonObject {
put("settled_by", "continue")
},
)
}
assertEquals(expectedParams, params)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectorsOperationWakeEncodesOwnerSessionOpIdAndProfileAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("op_id", "op-wake-777")
put("status", "running")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.operation.wake") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTORS_OPERATION_WAKE,
ConnectorsOperationStatusParams(
owner = ConnectorOwner.session("sess-wake-99"),
opId = "op-wake-777",
profile = "custom-profile",
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-wake-99")
},
)
put("op_id", "op-wake-777")
put("profile", "custom-profile")
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
@Test
fun hermesRpcCallerCallDelegatesForWakeWithAccountOwnerAndNullProfile() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("op_id", "op-wake-888")
put("status", "done")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.operation.wake") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesRpcCaller.call(
RpcMethods.CONNECTORS_OPERATION_WAKE,
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-wake-888",
profile = null,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-wake-888")
}
assertEquals(expectedParams, params)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
}
@@ -0,0 +1,358 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams
import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
import com.m57.hermescontrol.data.ws.contract.RpcMethods
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientConnectorsTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeoutOrNull(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedCallConnectorsConnectEncodesOwnerAccountReconnectTrueAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("op_id", "op-connect-123")
put("status", "running")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.connect") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTORS_CONNECT,
ConnectorsConnectParams(
owner = ConnectorOwner.account(),
connectors = listOf("slack", "github"),
reconnect = true,
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("connectors.connect", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put(
"connectors",
buildJsonArray {
add(JsonPrimitive("slack"))
add(JsonPrimitive("github"))
},
)
put("reconnect", true)
}
assertEquals(expectedParams, params)
assertEquals(true, (params?.get("reconnect") as? JsonPrimitive)?.booleanOrNull)
assertFalse(params?.containsKey("profile") ?: true)
assertEquals(serverResultJson, result)
}
@Test
fun typedCallConnectorsPolicySetEncodesNestedChangeWithArrayAndReturnsRawServerJsonUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("revision", 5)
put("applied", true)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.policy.set") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val nestedChange =
buildJsonObject {
put("policy", "whitelist")
put(
"allowed_connectors",
buildJsonArray {
add(JsonPrimitive("notion"))
add(JsonPrimitive("linear"))
},
)
put(
"rules",
buildJsonObject {
put("require_auth", true)
},
)
}
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.CONNECTORS_POLICY_SET,
ConnectorsPolicySetParams(
change = nestedChange,
expectedRevision = "4",
profile = "team-profile",
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("connectors.policy.set", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put("change", nestedChange)
put("expected_revision", "4")
put("profile", "team-profile")
}
assertEquals(expectedParams, params)
assertEquals(nestedChange, params?.get("change"))
assertEquals(serverResultJson, result)
}
@Test
fun hermesRpcCallerCallDelegatesToHermesWsClientCallAndEncodesFrameParamsUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put(
"connectors",
buildJsonArray {
add(
buildJsonObject {
put("id", "c1")
put("status", "connected")
},
)
},
)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "connectors.list") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesRpcCaller.call(
RpcMethods.CONNECTORS_LIST,
ConnectorsListParams(
owner = ConnectorOwner.session("s1"),
),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("connectors.list", (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
val expectedParams =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "s1")
},
)
}
assertEquals(expectedParams, params)
assertEquals(serverResultJson, result)
}
}
@@ -11,7 +11,6 @@ import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.coroutines.launch
@@ -30,11 +29,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class HermesWsClientLoggingSecurityTest {
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
@@ -87,17 +83,9 @@ class HermesWsClientLoggingSecurityTest {
CookieManager.setJarForTest(buildFakePersistentCookieJar())
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as AtomicInteger).set(1)
HermesWsClient.connectedForTest.set(false)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(1)
HermesWsClient.rejectAllPending()
HermesWsClient.disconnect(clearPendingMessages = true)
@@ -113,51 +101,20 @@ class HermesWsClientLoggingSecurityTest {
unmockkAll()
}
private fun createWsListener(generation: Int? = null): WebSocketListener {
val targetGen =
generation ?: run {
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as AtomicInteger).get()
}
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
return constructor.newInstance(targetGen) as WebSocketListener
}
private fun createWsListener(generation: Int? = null): WebSocketListener =
HermesWsClient.createListenerForTest(generation ?: HermesWsClient.connectionGenerationForTest.get())
private fun attachMockSocket(): WebSocket {
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
HermesWsClient.webSocketForTest = socket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.intentionalCloseForTest.set(false)
return socket
}
@Suppress("UNCHECKED_CAST")
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
field.isAccessible = true
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
}
private fun getTimeoutJob(pendingCall: Any): Job? {
val field = pendingCall.javaClass.getDeclaredField("timeoutJob")
field.isAccessible = true
return field.get(pendingCall) as? Job
}
// ── Formatter unit tests ──────────────────────────────────────────────────
@Test
@@ -255,8 +212,7 @@ class HermesWsClientLoggingSecurityTest {
val listener = createWsListener()
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
val pendingCalls = getPendingCalls()
val reqId = pendingCalls.keys.firstOrNull()
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
assertNotNull("Pending call ID must exist", reqId)
val sensitiveAuthUrl =
@@ -332,8 +288,7 @@ class HermesWsClientLoggingSecurityTest {
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
val pendingCalls = getPendingCalls()
val reqId = pendingCalls.keys.firstOrNull()
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
assertNotNull("Pending call ID must exist", reqId)
val errorJson =
@@ -434,15 +389,9 @@ class HermesWsClientLoggingSecurityTest {
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
attachMockSocket()
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
val pendingCalls = getPendingCalls()
val entry =
pendingCalls.entries.firstOrNull {
it.value.javaClass.name
.contains("PendingCall")
}
assertNotNull("Pending call must be registered in pendingCalls map", entry)
val reqId = entry!!.key
val timeoutJob = getTimeoutJob(entry.value)
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
assertNotNull("Pending call must be registered in pendingCalls map", reqId)
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId!!)
assertNotNull("Timeout job must be initialized", timeoutJob)
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
@@ -451,7 +400,7 @@ class HermesWsClientLoggingSecurityTest {
assertTrue("Deferred must be cancelled", deferred.isCancelled)
assertFalse(
"Pending call must be removed from pendingCalls map on cancellation",
pendingCalls.containsKey(reqId),
HermesWsClient.pendingCallIdsForTest().contains(reqId),
)
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
}
@@ -0,0 +1,281 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams
import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientSessionControlTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun sendRedirectPutsExpectedFrameAndInvokesOnSent() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.redirect") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.sendRedirect(
sessionId = "sess-red-1",
text = "change topic to testing",
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("session.redirect", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-red-1")
put("text", "change topic to testing")
}
assertEquals(expectedParams, params)
assertEquals(setOf("session_id", "text"), params!!.keys)
}
@Test
fun typedSendSessionInterruptPutsExpectedFrameAndInvokesOnSent() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.interrupt") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.send(
method = RpcMethods.SESSION_INTERRUPT,
params = SessionInterruptParams(sessionId = "s1"),
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("session.interrupt", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "s1")
}
assertEquals(expectedParams, params)
assertEquals(setOf("session_id"), params!!.keys)
}
@Test
fun typedSendSessionSteerPutsExpectedFrameAndInvokesOnSent() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.steer") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.send(
method = RpcMethods.SESSION_STEER,
params = SessionCorrectionParams(sessionId = "s1", text = "steer towards plan B"),
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("session.steer", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "s1")
put("text", "steer towards plan B")
}
assertEquals(expectedParams, params)
assertEquals(setOf("session_id", "text"), params!!.keys)
}
}
@@ -0,0 +1,584 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.PromptBtwParams
import com.m57.hermescontrol.data.ws.contract.PromptBtwResult
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionBranchParams
import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams
import com.m57.hermescontrol.data.ws.contract.SessionCompressParams
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import com.m57.hermescontrol.data.ws.contract.SessionListParams
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientSessionMiscTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedSendSessionListPutsEmptyParamsOnWireWithoutActiveProfile() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.list") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.send(
method = RpcMethods.SESSION_LIST,
params = SessionListParams,
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("session.list", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
// An empty params object is omitted on the wire (encodeDefaults=false), same as before the migration.
val params = frame["params"] as? JsonObject
assertTrue("session.list carries no params", params == null || params.isEmpty())
}
@Test
fun typedSendSessionListWithActiveProfileInjectsProfileOnWire() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.list") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-sessions")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_LIST,
params = SessionListParams,
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("profile", "active-profile-sessions")
}
assertEquals(expectedParams, params)
}
@Test
fun typedCallPromptBtwRoundTripsTaskIdAndReturnsPromptBtwResult() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.btw") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"task_id":"task-async-123","status":"queued"}}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: PromptBtwResult =
runBlocking {
HermesWsClient.call(
RpcMethods.PROMPT_BTW,
PromptBtwParams(sessionId = "sess-btw-call", text = "remember this detail"),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("prompt.btw", (frame["method"] as? JsonPrimitive)?.content)
val expectedParams =
buildJsonObject {
put("session_id", "sess-btw-call")
put("text", "remember this detail")
}
assertEquals(expectedParams, frame["params"])
assertEquals("task-async-123", result.taskId)
}
@Test
fun typedCallSessionUsageReturnsRawResultJsonElementUntouched() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResultJson =
buildJsonObject {
put("prompt_tokens", 1024)
put("completion_tokens", 256)
put("total_tokens", 1280)
put("cost_usd", 0.015)
put(
"nested_stats",
buildJsonObject {
put("cache_read_tokens", 512)
put("reasoning_tokens", 64)
},
)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.usage") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_USAGE,
SessionIdParams(sessionId = "s1"),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("session.usage", (frame["method"] as? JsonPrimitive)?.content)
val expectedParams =
buildJsonObject {
put("session_id", "s1")
}
assertEquals(expectedParams, frame["params"])
assertEquals(serverResultJson, result)
}
@Test
fun typedCallSessionCompressWithCustomTimeoutPutsExactWirePayload() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.compress") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"status":"compressed"}}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
method = RpcMethods.SESSION_COMPRESS,
params = SessionCompressParams(sessionId = "sess-long-comp", focusTopic = "deep-dive"),
timeoutMs = 300_000L,
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("session.compress", (frame["method"] as? JsonPrimitive)?.content)
val expectedParams =
buildJsonObject {
put("session_id", "sess-long-comp")
put("focus_topic", "deep-dive")
}
assertEquals(expectedParams, frame["params"])
val expectedResult =
buildJsonObject {
put("status", "compressed")
}
assertEquals(expectedResult, result)
}
@Test
fun typedCallSessionBranchPutsExactWirePayloadAndDecodesPassthroughResult() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResult =
buildJsonObject {
put("new_session_id", "sess-branched-99")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.branch") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResult}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_BRANCH,
SessionBranchParams(sessionId = "sess-orig-1", name = "branch-name"),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("session.branch", (frame["method"] as? JsonPrimitive)?.content)
val expectedParams =
buildJsonObject {
put("session_id", "sess-orig-1")
put("name", "branch-name")
}
assertEquals(expectedParams, frame["params"])
assertEquals(serverResult, result)
}
@Test
fun typedCallSessionBranchWholePutsExactWirePayloadAndDecodesPassthroughResult() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResult =
buildJsonObject {
put("whole_branch_session_id", "sess-whole-branched-101")
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.branch_whole") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResult}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_BRANCH_WHOLE,
SessionBranchWholeParams(sessionId = "sess-whole-orig-1", name = null),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("session.branch_whole", (frame["method"] as? JsonPrimitive)?.content)
val expectedParams =
buildJsonObject {
put("session_id", "sess-whole-orig-1")
}
assertEquals(expectedParams, frame["params"])
assertEquals(serverResult, result)
}
@Test
fun typedCallSessionContextBreakdownPutsExactWirePayloadAndDecodesPassthroughResult() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
val serverResult =
buildJsonObject {
put("system_prompt", 450)
put("messages", 2300)
put("tools", 800)
}
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.context_breakdown") {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":$serverResult}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result: JsonElement =
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_CONTEXT_BREAKDOWN,
SessionIdParams(sessionId = "sess-bd-1"),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals("session.context_breakdown", (frame["method"] as? JsonPrimitive)?.content)
val expectedParams =
buildJsonObject {
put("session_id", "sess-bd-1")
}
assertEquals(expectedParams, frame["params"])
assertEquals(serverResult, result)
}
}
@@ -91,27 +91,15 @@ class HermesWsClientTest {
every { DashboardSessionTokenRefresher.refresh() } returns null
// Reset state
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
statusField.isAccessible = true
@Suppress("UNCHECKED_CAST")
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.DISCONNECTED
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
HermesWsClient.connectedForTest.set(false)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
HermesWsClient.messageQueueForTest.clear()
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
acceptQueuedMessagesField.isAccessible = true
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.acceptQueuedMessagesForTest.set(true)
}
@After
@@ -350,10 +338,8 @@ class HermesWsClientTest {
val socket = mockk<WebSocket>()
every { socket.send(any<String>()) } returns true
every { socket.close(any(), any()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
socketField.set(HermesWsClient, socket)
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.webSocketForTest = socket
HermesWsClient.connectedForTest.set(true)
val sent =
HermesWsClient.respondToServerRequest(
@@ -384,10 +370,8 @@ class HermesWsClientTest {
val socket = mockk<WebSocket>()
every { socket.send(any<String>()) } returns true
every { socket.close(any(), any()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
socketField.set(HermesWsClient, socket)
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.webSocketForTest = socket
HermesWsClient.connectedForTest.set(true)
assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported"))
verify {
@@ -416,29 +400,14 @@ class HermesWsClientTest {
val staleSocket = mockk<WebSocket>(relaxed = true)
every { staleSocket.send(any<String>()) } returns false
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, staleSocket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
statusField.isAccessible = true
@Suppress("UNCHECKED_CAST")
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.CONNECTED
HermesWsClient.webSocketForTest = staleSocket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.CONNECTED
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertFalse(HermesWsClient.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
assertEquals(1, queue.size)
@@ -446,8 +415,7 @@ class HermesWsClientTest {
@Test
fun testSendRegistersRequestIdWhileOutboundLockHeld() {
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true }
val outboundLock = lockField.get(HermesWsClient)
val outboundLock = HermesWsClient.outboundLockForTest
var callbackHeldLock = false
HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) })
@@ -458,25 +426,13 @@ class HermesWsClientTest {
@Test
fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() {
every { AuthManager.isAutoReconnect() } returns true
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectedForTest.set(true)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
val oldGeneration = generation.get()
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val staleListener = constructor.newInstance(oldGeneration) as WebSocketListener
HermesWsClient.webSocketForTest = socket
val oldGeneration = HermesWsClient.connectionGenerationForTest.get()
val staleListener = HermesWsClient.createListenerForTest(oldGeneration)
var replacementOpens = 0
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
@@ -486,10 +442,10 @@ class HermesWsClientTest {
verify(exactly = 1) { socket.cancel() }
assertEquals(1, replacementOpens)
assertEquals(oldGeneration + 1, generation.get())
assertEquals(oldGeneration + 1, HermesWsClient.connectionGenerationForTest.get())
assertFalse(HermesWsClient.isConnected)
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
assertEquals(null, socketField.get(HermesWsClient))
assertEquals(null, HermesWsClient.webSocketForTest)
assertTrue(deferred.isCompleted)
}
@@ -497,18 +453,10 @@ class HermesWsClientTest {
fun testNetworkLossPreservesAuthExpiredStatus() =
runBlocking {
every { AuthManager.isAutoReconnect() } returns true
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
statusField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
status.value = ConnectionStatus.AUTH_EXPIRED
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
val socket = mockk<WebSocket>(relaxed = true)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
HermesWsClient.webSocketForTest = socket
HermesWsClient.reconnectForNetworkChange(false)
HermesWsClient.reconnectForNetworkChange(true)
@@ -520,22 +468,13 @@ class HermesWsClientTest {
@Test
fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() {
every { AuthManager.isAutoReconnect() } returns true
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectedForTest.set(true)
val oldSocket = mockk<WebSocket>(relaxed = true)
every { oldSocket.send(any<String>()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, oldSocket)
HermesWsClient.webSocketForTest = oldSocket
HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true))
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertTrue(queue.isEmpty())
HermesWsClient.reconnectForNetworkChange(false)
@@ -545,13 +484,7 @@ class HermesWsClientTest {
HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ }
val replacementSocket = mockk<WebSocket>(relaxed = true)
every { replacementSocket.send(any<String>()) } returns true
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val replacementListener = constructor.newInstance(generation.get()) as WebSocketListener
val replacementListener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
replacementListener.onOpen(replacementSocket, mockk(relaxed = true))
assertEquals(1, replacementOpens)
@@ -563,18 +496,10 @@ class HermesWsClientTest {
@Test
fun testNetworkChangePreservesAuthExpiredSocket() {
every { AuthManager.isAutoReconnect() } returns true
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
statusField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
status.value = ConnectionStatus.AUTH_EXPIRED
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
val socket = mockk<WebSocket>(relaxed = true)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
HermesWsClient.webSocketForTest = socket
HermesWsClient.reconnectForNetworkChange()
@@ -588,20 +513,13 @@ class HermesWsClientTest {
every { AuthManager.isAutoReconnect() } returns true
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.currentBackoffForTest = 1_000L
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
backoffField.isAccessible = true
backoffField.setLong(HermesWsClient, 1_000L)
HermesWsClient.scheduleReconnectForTest()
HermesWsClient.scheduleReconnectForTest()
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
reconnectMethod.isAccessible = true
reconnectMethod.invoke(HermesWsClient)
reconnectMethod.invoke(HermesWsClient)
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
}
@Test
@@ -610,35 +528,21 @@ class HermesWsClientTest {
every { AuthManager.isAutoReconnect() } returns true
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectedForTest.set(false)
HermesWsClient.currentBackoffForTest = 0L
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
backoffField.isAccessible = true
backoffField.setLong(HermesWsClient, 0L)
val reconnectJobField = HermesWsClient::class.java.getDeclaredField("reconnectJob")
reconnectJobField.isAccessible = true
val replacementJob = mockk<Job>(relaxed = true)
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock")
lockField.isAccessible = true
val lock = lockField.get(HermesWsClient)
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
reconnectMethod.isAccessible = true
val lock = HermesWsClient.outboundLockForTest
synchronized(lock) {
reconnectMethod.invoke(HermesWsClient)
reconnectJobField.set(HermesWsClient, replacementJob)
HermesWsClient.scheduleReconnectForTest()
HermesWsClient.reconnectJobForTest = replacementJob
}
Thread.sleep(100)
assertSame(replacementJob, reconnectJobField.get(HermesWsClient))
assertSame(replacementJob, HermesWsClient.reconnectJobForTest)
}
@Test
@@ -646,23 +550,15 @@ class HermesWsClientTest {
val staleSocket = mockk<WebSocket>(relaxed = true)
every { staleSocket.send(any<String>()) } returns false
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, staleSocket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.webSocketForTest = staleSocket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.send(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
)
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertTrue(queue.isEmpty())
io.mockk.verify(exactly = 0) { staleSocket.cancel() }
}
@@ -674,10 +570,7 @@ class HermesWsClientTest {
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
)
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertTrue(queue.isEmpty())
}
@@ -687,24 +580,14 @@ class HermesWsClientTest {
every { pressuredSocket.send(any<String>()) } returns false
every { pressuredSocket.queueSize() } returns 1024L
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, pressuredSocket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.webSocketForTest = pressuredSocket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
drainJobField.isAccessible = true
assertNotNull(drainJobField.get(HermesWsClient))
assertNotNull(HermesWsClient.outboundDrainJobForTest)
}
@Test
@@ -717,35 +600,18 @@ class HermesWsClientTest {
every { pressuredSocket.send(any<String>()) } returns false
every { pressuredSocket.queueSize() } returns 1024L
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, pressuredSocket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
backoffField.isAccessible = true
backoffField.setLong(HermesWsClient, 1_000L)
HermesWsClient.webSocketForTest = pressuredSocket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(1)
HermesWsClient.currentBackoffForTest = 1_000L
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val listener = constructor.newInstance(1) as WebSocketListener
val listener = HermesWsClient.createListenerForTest(1)
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
}
@Test
@@ -754,38 +620,22 @@ class HermesWsClientTest {
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
every { pressuredSocket.queueSize() } returns 1024L
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(1)
HermesWsClient.webSocketForTest = pressuredSocket
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, pressuredSocket)
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val listener = constructor.newInstance(1) as WebSocketListener
val listener = HermesWsClient.createListenerForTest(1)
listener.onOpen(pressuredSocket, mockk(relaxed = true))
assertFalse(HermesWsClient.isConnected)
assertEquals(1, queue.size)
assertTrue(queue.peek()?.contains("\"2\"") == true)
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
drainJobField.isAccessible = true
assertNotNull(drainJobField.get(HermesWsClient))
assertNotNull(HermesWsClient.outboundDrainJobForTest)
}
@Test
@@ -794,29 +644,15 @@ class HermesWsClientTest {
val currentSocket = mockk<WebSocket>(relaxed = true)
every { currentSocket.send(any<String>()) } returns true
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, currentSocket)
HermesWsClient.webSocketForTest = currentSocket
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(2)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(2)
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val staleListener = constructor.newInstance(1) as WebSocketListener
val currentListener = constructor.newInstance(2) as WebSocketListener
val staleListener = HermesWsClient.createListenerForTest(1)
val currentListener = HermesWsClient.createListenerForTest(2)
val response = mockk<okhttp3.Response>(relaxed = true)
staleListener.onOpen(staleSocket, response)
@@ -835,40 +671,24 @@ class HermesWsClientTest {
false
}
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, staleSocket)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.webSocketForTest = staleSocket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertTrue(queue.isEmpty())
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
}
@Test
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertEquals(1, queue.size)
HermesWsClient.disconnect()
@@ -886,16 +706,11 @@ class HermesWsClientTest {
@Test
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.intentionalCloseForTest.set(false)
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
queueField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
val queue = HermesWsClient.messageQueueForTest
assertEquals(1, queue.size)
HermesWsClient.rejectAllPending()
@@ -910,26 +725,12 @@ class HermesWsClientTest {
every { socket.send(any<String>()) } returns false
every { socket.queueSize() } returns 0L
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
HermesWsClient.webSocketForTest = socket
HermesWsClient.connectedForTest.set(true)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(1)
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val listener = constructor.newInstance(1) as WebSocketListener
val listener = HermesWsClient.createListenerForTest(1)
listener.onClosing(socket, 4401, "expired")
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
@@ -941,21 +742,10 @@ class HermesWsClientTest {
@Test
fun testSocketFailureDoesNotOverwriteAuthExpired() {
val socket = mockk<WebSocket>(relaxed = true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
statusField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
status.value = ConnectionStatus.AUTH_EXPIRED
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val listener = constructor.newInstance(1) as WebSocketListener
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(1)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
val listener = HermesWsClient.createListenerForTest(1)
listener.onFailure(socket, IOException("network changed"), null)
@@ -965,18 +755,10 @@ class HermesWsClientTest {
@Test
fun testExplicitConnectRetriesAfterAuthExpired() =
runBlocking {
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
statusField.isAccessible = true
@Suppress("UNCHECKED_CAST")
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
status.value = ConnectionStatus.AUTH_EXPIRED
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
HermesWsClient.connectedForTest.set(true)
val oldSocket = mockk<WebSocket>(relaxed = true)
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, oldSocket)
HermesWsClient.webSocketForTest = oldSocket
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
@@ -1008,17 +790,16 @@ class HermesWsClientTest {
HermesWsClient.disconnect(clearPendingMessages = true)
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
val disconnectedGeneration = generation.get()
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
val replacementConnect = thread { HermesWsClient.connect() }
val generationDeadline = System.currentTimeMillis() + 5_000
while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) {
while (HermesWsClient.connectionGenerationForTest.get() == disconnectedGeneration &&
System.currentTimeMillis() < generationDeadline
) {
Thread.sleep(10)
}
assertTrue(generation.get() > disconnectedGeneration)
val currentGeneration = generation.get()
assertTrue(HermesWsClient.connectionGenerationForTest.get() > disconnectedGeneration)
val currentGeneration = HermesWsClient.connectionGenerationForTest.get()
releaseRefresh.countDown()
staleConnect.join(5_000)
@@ -1029,7 +810,7 @@ class HermesWsClientTest {
assertFalse(staleConnect.isAlive)
assertFalse(replacementConnect.isAlive)
assertEquals(currentGeneration, generation.get())
assertEquals(currentGeneration, HermesWsClient.connectionGenerationForTest.get())
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
verify(exactly = 0) { AuthManager.setToken("stale-token") }
}
@@ -1441,12 +1222,10 @@ class HermesWsClientTest {
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
// After connect, backoff should be back to initial
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
backoffField.isAccessible = true
assertEquals(
"Backoff should reset to initial after successful connect",
1000L,
backoffField.getLong(HermesWsClient),
HermesWsClient.currentBackoffForTest,
)
}
@@ -1999,26 +1778,11 @@ class HermesWsClientTest {
fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() {
// Issue #1163: exercise the real listener, including the early duplicate return.
val socket = mockk<WebSocket>(relaxed = true)
val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalClose.isAccessible = true
(intentionalClose.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
val generation = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generation.isAccessible = true
val constructor =
Class
.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
.declaredConstructors
.single()
constructor.isAccessible = true
val listener =
constructor.newInstance(
(generation.get(HermesWsClient) as AtomicInteger).get(),
) as WebSocketListener
HermesWsClient.intentionalCloseForTest.set(false)
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
// Directly install the reflection-built listener so onMessage runs even
// though this test never performed a real OkHttp connect.
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
socketField.isAccessible = true
socketField.set(HermesWsClient, socket)
HermesWsClient.webSocketForTest = socket
mockkObject(EventParser)
val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]")
@@ -2039,18 +1803,10 @@ class HermesWsClientTest {
@Test
fun testTerminalCloseCode4403SetsAuthExpired() {
val socket = mockk<WebSocket>(relaxed = true)
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
intentionalCloseField.isAccessible = true
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.intentionalCloseForTest.set(false)
HermesWsClient.connectionGenerationForTest.set(1)
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
generationField.isAccessible = true
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
val constructor = listenerClass.declaredConstructors.single()
constructor.isAccessible = true
val listener = constructor.newInstance(1) as WebSocketListener
val listener = HermesWsClient.createListenerForTest(1)
listener.onClosing(socket, 4403, "forbidden origin")
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
@@ -2182,25 +1938,22 @@ class HermesWsClientTest {
@Test
fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() {
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
HermesWsClient.connectedForTest.set(true)
val failureLatch = CountDownLatch(1)
HermesWsClient.probeLivenessOnTransportChange(
timeoutMs = 50L,
onFailureAction = {
failureLatch.countDown()
},
)
assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(6, TimeUnit.SECONDS))
assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(3, TimeUnit.SECONDS))
}
@Test
fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() {
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
connectedField.isAccessible = true
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
HermesWsClient.connectedForTest.set(false)
var failureInvoked = false
HermesWsClient.probeLivenessOnTransportChange(
@@ -0,0 +1,532 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
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.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientTypedCallTest {
private lateinit var mockWebServer: MockWebServer
@Serializable
private data class ScopedDummyParams(
val query: String,
val profile: String? = null,
)
@Serializable
private data class ScopedDummyResult(
val status: String? = null,
)
private val sessionListMethod =
RpcMethod(
name = WsMethods.SESSION_LIST,
params = ScopedDummyParams.serializer(),
result = ScopedDummyResult.serializer(),
)
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun callPutsExactFrameOnTheWire() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == WsMethods.SESSION_EVENTS_SINCE) {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"latest_seq":7,"truncated":false}}""",
)
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_EVENTS_SINCE,
SessionEventsSinceParams(sessionId = "s1", lastSeen = 7),
)
}
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertEquals(WsMethods.SESSION_EVENTS_SINCE, (frame["method"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "s1")
put("last_seen", 7)
}
assertEquals(expectedParams, params)
val lastSeenElement = params!!["last_seen"] as? JsonPrimitive
assertNotNull(lastSeenElement)
assertTrue(lastSeenElement!!.isString.not())
assertEquals(7, lastSeenElement.content.toInt())
}
@Test
fun callDecodesTypedResultOnSuccess() {
val opened = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep-42","latest_seq":100,"truncated":true,"events":[{"type":"test"}]}}""",
)
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val result =
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_EVENTS_SINCE,
SessionEventsSinceParams(sessionId = "s1", lastSeen = 5),
)
}
assertEquals("ep-42", result.epoch)
assertEquals(100, result.latestSeq)
assertEquals(true, result.truncated)
assertEquals(1, result.events?.size)
}
@Test
fun callThrowsHermesRpcExceptionOnErrorResponse() {
val opened = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send(
"""{"jsonrpc":"2.0","id":"$id","error":{"code":4001,"message":"Access denied: session ownership required","data":{"reason":"NOT_OWNER"}}}""",
)
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
try {
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_EVENTS_SINCE,
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
)
}
fail("Expected HermesRpcException to be thrown")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals(4001, e.code)
assertEquals("Access denied: session ownership required", e.message)
val dataObj = e.data as? JsonObject
assertNotNull(dataObj)
assertEquals("NOT_OWNER", (dataObj!!["reason"] as? JsonPrimitive)?.content)
}
}
@Test
fun profileScopingInjectsActiveProfileWhenSet() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == WsMethods.SESSION_LIST) {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
}
}
},
),
)
AuthManager.setActiveProfileId("work-profile")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
runBlocking {
HermesWsClient.call(
sessionListMethod,
ScopedDummyParams(query = "active"),
)
}
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
assertEquals("work-profile", (params!!["profile"] as? JsonPrimitive)?.content)
assertEquals("active", (params["query"] as? JsonPrimitive)?.content)
}
@Test
fun profileScopingPreservesExplicitProfile() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == WsMethods.SESSION_LIST) {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
}
}
},
),
)
AuthManager.setActiveProfileId("work-profile")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
runBlocking {
HermesWsClient.call(
sessionListMethod,
ScopedDummyParams(query = "active", profile = "explicit-override"),
)
}
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
assertEquals("explicit-override", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun profileScopingDoesNotInjectWhenNoActiveProfile() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == WsMethods.SESSION_LIST) {
frameRef.set(json)
captured.countDown()
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
runBlocking {
HermesWsClient.call(
sessionListMethod,
ScopedDummyParams(query = "active"),
)
}
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
assertFalse("Profile key must not be present when no active profile", params!!.containsKey("profile"))
}
@Test
fun callFailsOnTimeoutWhenServerDoesNotReply() {
val opened = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
// Do not reply
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
val thrown =
runCatching {
runBlocking {
HermesWsClient.call(
RpcMethods.SESSION_EVENTS_SINCE,
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
timeoutMs = 50L,
)
}
}.exceptionOrNull()
assertTrue(
"Timeout must surface as HermesRpcException, got: $thrown",
thrown is HermesWsClient.HermesRpcException,
)
val rpc = thrown as HermesWsClient.HermesRpcException
assertEquals(-1, rpc.code)
assertTrue(rpc.message.orEmpty().contains("timed out"))
}
@Test
fun cancellingCallingCoroutineCleansPendingCall() {
val opened = CountDownLatch(1)
val requestReceived = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
requestReceived.countDown()
// Do not reply, keeping the call pending
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
runBlocking {
val callJob =
async(Dispatchers.IO) {
HermesWsClient.call(
RpcMethods.SESSION_EVENTS_SINCE,
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
timeoutMs = 10_000L,
)
}
assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS))
assertEquals(
"Should have exactly 1 pending call while awaiting",
1,
HermesWsClient.pendingCallIdsForTest().size,
)
callJob.cancel()
// Wait briefly for cancellation cleanup to execute
withTimeout(2000) {
while (HermesWsClient.pendingCallIdsForTest().isNotEmpty()) {
kotlinx.coroutines.delay(20)
}
}
assertTrue(
"Pending calls should be empty after caller coroutine is cancelled",
HermesWsClient.pendingCallIdsForTest().isEmpty(),
)
}
}
}
@@ -0,0 +1,484 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
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.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class HermesWsClientTypedSendTest {
private lateinit var mockWebServer: MockWebServer
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
mockWebServer = MockWebServer()
mockWebServer.start()
mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
every { AuthManager.serverStore } returns
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
every { it.getLatestState() } returns
com.m57.hermescontrol.data.config
.ServerStoreState()
}
CookieManager.setJarForTest(buildFakePersistentCookieJar())
mockkObject(DashboardSessionTokenRefresher)
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
every { DashboardSessionTokenRefresher.refresh() } returns null
HermesWsClient.disconnect(clearPendingMessages = true)
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.setAppForeground(true)
AuthManager.resetAuthStateForTest()
}
@After
fun tearDown() {
AuthManager.resetAuthStateForTest()
HermesWsClient.releaseExternalActivityConnectionLease()
HermesWsClient.releaseBackgroundConnectionLease()
HermesWsClient.disconnect(clearPendingMessages = true)
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
}
runCatching { mockWebServer.shutdown() }
unmockkAll()
}
private fun connectClient() {
HermesWsClient.connect()
runBlocking {
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
}
}
@Test
fun typedSendSessionResumePutsExpectedFrameAndInvokesOnSent() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.resume") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.send(
method = RpcMethods.SESSION_RESUME,
params = SessionResumeParams(sessionId = "sess-1", omitMessages = true),
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
assertEquals("session.resume", (frame["method"] as? JsonPrimitive)?.content)
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-1")
put("omit_messages", true)
}
assertEquals(expectedParams, params)
}
@Test
fun typedSendWithActiveProfileInjectsProfileIntoSessionResume() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.resume") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-1")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_RESUME,
params = SessionResumeParams(sessionId = "sess-active", omitMessages = true),
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-active")
put("omit_messages", true)
put("profile", "active-profile-1")
}
assertEquals(expectedParams, params)
assertEquals("active-profile-1", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun typedSendExplicitProfileWinsOverActiveProfileForSessionResume() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.resume") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-1")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_RESUME,
params =
SessionResumeParams(
sessionId = "sess-active",
omitMessages = true,
profile = "explicit-resume-profile",
),
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-active")
put("omit_messages", true)
put("profile", "explicit-resume-profile")
}
assertEquals(expectedParams, params)
assertEquals("explicit-resume-profile", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun sessionCreateWithExplicitProfileKeepsExplicitWhenDifferentProfileActive() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "session.create") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
AuthManager.setActiveProfileId("active-profile-2")
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.send(
method = RpcMethods.SESSION_CREATE,
params = SessionCreateParams(profile = "bot-1"),
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("profile", "bot-1")
}
assertEquals(expectedParams, params)
assertEquals("bot-1", (params!!["profile"] as? JsonPrimitive)?.content)
}
@Test
fun sendMessagePutsSessionIdAndTextOnTheWire() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.submit") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
var onSentId: String? = null
val returnedId =
HermesWsClient.sendMessage(
sessionId = "sess-msg-1",
text = "plain message",
onSent = { id -> onSentId = id },
)
assertTrue(returnedId.isNotBlank())
assertEquals(returnedId, onSentId)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-msg-1")
put("text", "plain message")
}
assertEquals(expectedParams, params)
assertFalse(params!!.containsKey("queued"))
}
@Test
fun sendMessageWithQueuedTrueAddsQueuedTrue() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.submit") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.sendMessage(
sessionId = "sess-msg-2",
text = "queued message",
queued = true,
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-msg-2")
put("text", "queued message")
put("queued", true)
}
assertEquals(expectedParams, params)
val queuedElement = params!!["queued"] as? JsonPrimitive
assertTrue(queuedElement != null && queuedElement.isString.not())
assertEquals("true", queuedElement!!.content)
}
@Test
fun sendMessageWithQueuedFalseHasNoQueuedKey() {
val opened = CountDownLatch(1)
val captured = CountDownLatch(1)
val frameRef = AtomicReference<JsonObject?>(null)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) = opened.countDown()
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
val name = (json["method"] as? JsonPrimitive)?.content
if (name == "prompt.submit") {
frameRef.set(json)
captured.countDown()
}
}
},
),
)
connectClient()
assertTrue(opened.await(5, TimeUnit.SECONDS))
HermesWsClient.sendMessage(
sessionId = "sess-msg-3",
text = "un为其queued message",
queued = false,
)
assertTrue(captured.await(5, TimeUnit.SECONDS))
val frame = requireNotNull(frameRef.get())
val params = frame["params"] as? JsonObject
assertNotNull(params)
val expectedParams =
buildJsonObject {
put("session_id", "sess-msg-3")
put("text", "un为其queued message")
}
assertEquals(expectedParams, params)
assertFalse(params!!.containsKey("queued"))
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
@@ -21,7 +22,7 @@ class ModelOptionsRepositoryTest {
ModelOptionsRepository(
connected = { true },
request = { params ->
assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params)
assertEquals(ModelOptionsParams(refresh = true, includeUnconfigured = false), params)
Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""")
},
rest = { error("REST must not run") },
@@ -16,6 +16,9 @@ import org.junit.Assert.fail
import org.junit.Test
class PluginManageRepositoryTest {
private fun pluginRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
PluginManageRepository(fakeCaller(handler))
// 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`() =
@@ -44,7 +47,7 @@ class PluginManageRepositoryTest {
)
val repository =
PluginManageRepository { method, params ->
pluginRepo { method, params ->
capturedMethod = method
capturedParams = params
mockPayload
@@ -80,7 +83,7 @@ class PluginManageRepositoryTest {
)
val repository =
PluginManageRepository { method, params ->
pluginRepo { method, params ->
capturedMethod = method
capturedParams = params
mockPayload
@@ -98,19 +101,19 @@ class PluginManageRepositoryTest {
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
runTest {
val emptyObjRepo =
PluginManageRepository { _, _ ->
pluginRepo { _, _ ->
OkHttpProvider.json.parseToJsonElement("{}")
}
assertTrue(emptyObjRepo.listPlugins().isEmpty())
val nonObjRepo =
PluginManageRepository { _, _ ->
pluginRepo { _, _ ->
OkHttpProvider.json.parseToJsonElement("[]")
}
assertTrue(nonObjRepo.listPlugins().isEmpty())
val nullRepo =
PluginManageRepository { _, _ ->
pluginRepo { _, _ ->
null
}
assertTrue(nullRepo.listPlugins().isEmpty())
@@ -139,7 +142,7 @@ class PluginManageRepositoryTest {
)
val repository =
PluginManageRepository { method, params ->
pluginRepo { method, params ->
capturedMethod = method
capturedParams = params
mockResponse
@@ -156,7 +159,7 @@ class PluginManageRepositoryTest {
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
assertEquals("settings", capturedParams["action"])
assertEquals("camofox", capturedParams["key"])
assertEquals(JsonObject(valuesToSave), capturedParams["values"])
assertEquals(mapOf("port" to 9222, "headless" to true), capturedParams["values"])
assertFalse(capturedParams.containsKey("profile"))
assertTrue(result.ok)
@@ -186,7 +189,7 @@ class PluginManageRepositoryTest {
)
val repository =
PluginManageRepository { method, params ->
pluginRepo { method, params ->
capturedMethod = method
capturedParams = params
mockResponse
@@ -204,7 +207,7 @@ class PluginManageRepositoryTest {
mapOf(
"action" to "settings",
"key" to "camofox",
"values" to JsonObject(valuesToSave),
"values" to mapOf("port" to 8080),
"profile" to "staging",
),
capturedParams,
@@ -222,7 +225,7 @@ class PluginManageRepositoryTest {
fun `saveSettings throws SerializationException when response is malformed`() =
runTest {
val repository =
PluginManageRepository { _, _ ->
pluginRepo { _, _ ->
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
}
@@ -9,13 +9,16 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PluginRemovalRepositoryTest {
private fun removalRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
PluginRemovalRepository(fakeCaller(handler))
@Test
fun `remove sends scoped gateway action and decodes success`() =
runTest {
var method = ""
var params: Map<String, Any> = emptyMap()
val repository =
PluginRemovalRepository { requestedMethod, requestedParams ->
removalRepo { requestedMethod, requestedParams ->
method = requestedMethod
params = requestedParams
Json.parseToJsonElement("""{"ok":true,"name":"demo"}""")
@@ -33,11 +36,11 @@ class PluginRemovalRepositoryTest {
@Test
fun `refusal is a typed failure and missing ok cannot masquerade as success`() =
runTest {
val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") }
val refused = removalRepo { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") }
assertFalse(refused.remove("builtin").ok)
assertEquals("bundled plugin", refused.remove("builtin").error)
val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") }
val malformed = removalRepo { _, _ -> mapOf("name" to "demo") }
try {
malformed.remove("demo")
throw AssertionError("Missing ok must fail decoding")
@@ -8,12 +8,17 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ProjectsSourceTest {
private fun projectsSource(
connected: Boolean = true,
handler: suspend (String, Map<String, Any>) -> Any?,
) = HermesProjectsSource(isConnected = { connected }, caller = fakeCaller(handler))
@Test
fun `decodes the untyped rpc map`() =
runTest {
var calledMethod: String? = null
val source =
HermesProjectsSource(isConnected = { true }) { method, _ ->
projectsSource { method, _ ->
calledMethod = method
mapOf(
"projects" to
@@ -49,13 +54,10 @@ class ProjectsSourceTest {
runTest {
var calls = 0
val source =
HermesProjectsSource(
rpcRequest = { _, _ ->
calls++
mapOf("projects" to emptyList<Any>())
},
isConnected = { false },
)
projectsSource(connected = false) { _, _ ->
calls++
mapOf("projects" to emptyList<Any>())
}
assertNull(source.fetchProjects())
assertEquals("a disconnected fetch must not queue an RPC", 0, calls)
@@ -65,10 +67,10 @@ class ProjectsSourceTest {
fun `rpc failure or unexpected payload yields null`() =
runTest {
assertNull(
HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(),
projectsSource { _, _ -> error("method not found") }.fetchProjects(),
)
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects())
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects())
assertNull(projectsSource { _, _ -> "nope" }.fetchProjects())
assertNull(projectsSource { _, _ -> null }.fetchProjects())
}
@Test
@@ -76,9 +78,7 @@ class ProjectsSourceTest {
runTest {
val thrown =
runCatching {
HermesProjectsSource(
isConnected = { true },
) { _, _ -> throw CancellationException("gone") }.fetchProjects()
projectsSource { _, _ -> throw CancellationException("gone") }.fetchProjects()
}.exceptionOrNull()
assertTrue(thrown is CancellationException)
}
@@ -0,0 +1,318 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.lang.reflect.Modifier
/**
* Ratchet guard preventing new raw Map-param request/send call sites for contract RPC methods (#1375).
*
* Background:
* Issue #1375 migrates raw `HermesWsClient.request(...)`/`.send(...)`/wrapper call sites that pass hand-built
* `Map<String, Any>` params to typed `HermesWsClient.call(RpcMethods.X, params)`.
*
* This test asserts that the set/multiset of raw call sites in `app/src/main/java` matches [BASELINE].
* - Adding a new raw call site fails CI: author must migrate to `HermesWsClient.call(RpcMethods.X, params)` or,
* for a not-yet-migrated method, consciously add the entry to [BASELINE].
* - Migrating a call site to typed `call(...)` makes the baseline entry stale, failing CI until removed.
*
* Scanner limitations / blind spots:
* 1. Indirect wrappers: does not catch wrappers that alias method names through intermediate variables or functions
* (e.g. `val m = WsMethods.FOO; send(m)` or helper functions accepting dynamic strings).
* 2. Raw string literals: does not catch direct string literals bypassing `WsMethods` (e.g. `send("session.list")`).
* 3. Dynamic dispatch: reflection or computed method identifiers are invisible to the static regex scanner.
* 4. Custom parameter maps wrapped in separate helper classes or extensions not calling standard request/send.
*/
class RawRpcParamsRatchetTest {
private val wsMethodConstants: Map<String, String> =
WsMethods::class.java.declaredFields
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
.associate { it.name to it.get(null) as String }
@Test
fun rawRpcCallSitesMatchBaselineRatchet() {
val rootDir = resolveMainJavaRoot()
val contractMethodNames = GatewayContract.methods
val contractConstantNames =
wsMethodConstants
.filter { (_, methodStr) -> methodStr in contractMethodNames }
.keys
val scannedSites = mutableListOf<String>()
rootDir
.walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
.forEach { file ->
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
val code = stripComments(file.readText())
val sites = scanRawRpcCalls(code, contractConstantNames)
for (site in sites) {
scannedSites.add("$relativePath::$site")
}
}
scannedSites.sort()
val staleEntries = BASELINE.filter { it !in scannedSites }
val newEntries = scannedSites.filter { it !in BASELINE }
val failureMessage =
buildString {
if (newEntries.isNotEmpty()) {
appendLine(
"raw call added: migrate to HermesWsClient.call(RpcMethods...) or, for a not-yet-migrated method, add to BASELINE:",
)
newEntries.forEach { appendLine(" + $it") }
}
if (staleEntries.isNotEmpty()) {
appendLine("stale baseline entry: remove it (call site was migrated or removed):")
staleEntries.forEach { appendLine(" - $it") }
}
}
assertEquals(
failureMessage.trimEnd(),
BASELINE,
scannedSites,
)
}
@Test
fun scannerSelfTestDetectsRawCallsAndIgnoresSafeUsages() {
val contractConstants = setOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT")
val sampleCode =
"""
// Commented call should be ignored:
// wsClient.request(WsMethods.SESSION_LIST, emptyMap())
/* Multi-line comment:
HermesWsClient.send(WsMethods.SESSION_LIST)
*/
// When branch routing should be ignored:
when (method) {
WsMethods.SESSION_LIST -> handleList()
WsMethods.SESSION_CREATE -> handleCreate()
}
// Comparison checks should be ignored:
if (method == WsMethods.SESSION_LIST) return
if (method != WsMethods.PROMPT_SUBMIT) return
// Tracking helpers should be ignored:
trackRequest(id, WsMethods.SESSION_LIST)
trackSessionRequest(id, WsMethods.SESSION_CREATE, generation)
// Positional single-line call:
HermesWsClient.request(WsMethods.SESSION_LIST, emptyMap())
// Named argument call across lines:
request(
method = WsMethods.SESSION_CREATE,
params = mapOf("profile" to "test"),
)
// Positional multi-line call:
wsClient.send(
WsMethods.PROMPT_SUBMIT,
mapOf("text" to "hello"),
)
""".trimIndent()
val cleaned = stripComments(sampleCode)
val detected = scanRawRpcCalls(cleaned, contractConstants)
assertEquals(
listOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT"),
detected,
)
}
companion object {
internal fun resolveMainJavaRoot(): File {
val userDir = File(System.getProperty("user.dir") ?: ".")
val candidates =
listOf(
File(userDir, "app/src/main/java/com/m57/hermescontrol"),
File(userDir, "src/main/java/com/m57/hermescontrol"),
File(userDir, "../app/src/main/java/com/m57/hermescontrol"),
)
return candidates.firstOrNull { it.isDirectory }
?: error("Could not resolve app/src/main/java/com/m57/hermescontrol from $userDir")
}
internal fun stripComments(text: String): String {
val result = StringBuilder(text.length)
var i = 0
val n = text.length
var inString = false
var inTripleString = false
while (i < n) {
if (!inString && !inTripleString) {
if (text.startsWith("\"\"\"", i)) {
inTripleString = true
result.append("\"\"\"")
i += 3
} else if (text[i] == '"') {
inString = true
result.append('"')
i++
} else if (text.startsWith("//", i)) {
var j = text.indexOf('\n', i)
if (j == -1) j = n
while (i < j) {
result.append(' ')
i++
}
} else if (text.startsWith("/*", i)) {
var depth = 1
var j = i + 2
while (j < n && depth > 0) {
if (text.startsWith("/*", j)) {
depth++
j += 2
} else if (text.startsWith("*/", j)) {
depth--
j += 2
} else {
j++
}
}
while (i < j) {
result.append(if (text[i] == '\n') '\n' else ' ')
i++
}
} else {
result.append(text[i])
i++
}
} else if (inTripleString) {
if (text.startsWith("\"\"\"", i)) {
inTripleString = false
result.append("\"\"\"")
i += 3
} else {
result.append(text[i])
i++
}
} else {
if (text[i] == '\\' && i + 1 < n) {
result.append(text[i]).append(text[i + 1])
i += 2
} else if (text[i] == '"') {
inString = false
result.append('"')
i++
} else {
result.append(text[i])
i++
}
}
}
return result.toString()
}
internal fun scanRawRpcCalls(
cleanCode: String,
contractConstants: Set<String>,
): List<String> {
val regex = Regex("""WsMethods\.([A-Za-z0-9_]+)""")
val results = mutableListOf<String>()
for (match in regex.findAll(cleanCode)) {
val constantName = match.groupValues[1]
if (constantName !in contractConstants) continue
val startIndex = match.range.first
if (isMethodArgumentOfRpcCall(cleanCode, startIndex)) {
results.add(constantName)
}
}
return results
}
private fun isMethodArgumentOfRpcCall(
cleanCode: String,
matchStart: Int,
): Boolean {
var i = matchStart - 1
while (i >= 0 && cleanCode[i].isWhitespace()) {
i--
}
var isNamedMethodParam = false
if (i >= 0 && cleanCode[i] == '=') {
var j = i - 1
while (j >= 0 && cleanCode[j].isWhitespace()) {
j--
}
val identEnd = j + 1
while (j >= 0 && cleanCode[j].isLetterOrDigit()) {
j--
}
val paramName = cleanCode.substring(j + 1, identEnd)
if (paramName == "method") {
isNamedMethodParam = true
i = j
while (i >= 0 && cleanCode[i].isWhitespace()) {
i--
}
} else {
return false
}
}
if (i < 0) return false
val delimiter = cleanCode[i]
if (delimiter != '(' && delimiter != ',') return false
if (delimiter == ',' && !isNamedMethodParam) {
// Positional method argument in Kotlin RPC calls is the first argument directly following '('
return false
}
var parenDepth = 0
var k = i
while (k >= 0) {
val c = cleanCode[k]
if (c == ')') {
parenDepth++
} else if (c == '(') {
if (parenDepth > 0) {
parenDepth--
} else {
var m = k - 1
while (m >= 0 && cleanCode[m].isWhitespace()) {
m--
}
val endFn = m + 1
while (m >= 0 &&
(cleanCode[m].isLetterOrDigit() || cleanCode[m] == '.' || cleanCode[m] == '_')
) {
m--
}
val fnName = cleanCode.substring(m + 1, endFn)
val lowerFn = fnName.lowercase()
if (fnName in setOf("if", "while", "for", "when")) return false
if (lowerFn.contains("track")) return false
return true
}
} else if (c == '{' || c == '}' || c == ';') {
return false
}
k--
}
return false
}
/**
* Multiset of remaining raw RPC call sites for contract methods in `app/src/main/java/com/m57/hermescontrol/`.
* Empty: every contract method goes through `RpcMethods`. Keep it empty; new contract methods must be typed.
* The untyped `request`/`send` stay module-internal for `gateway.ping` and the legacy `*.respond` fallbacks.
*/
val BASELINE: List<String> = emptyList()
}
}
@@ -0,0 +1,98 @@
package com.m57.hermescontrol.data.ws
import org.junit.Assert.assertEquals
import org.junit.Test
class ReconnectPolicyTest {
@Test
fun defaultInitialBackoffIs1000() {
val policy = ReconnectPolicy()
assertEquals(1000L, policy.currentBackoff)
}
@Test
fun nextDelayMsUsesInjectedRandomAndFloorsAt300() {
// With random = 0.0, calculated delay is 0, floored to 300 (or ceiling if ceiling < 300)
val minPolicy = ReconnectPolicy(random = { 0.0 })
assertEquals(300L, minPolicy.nextDelayMs())
// With random = 0.5 and ceiling = 1000, 0.5 * 1000 = 500
val midPolicy = ReconnectPolicy(random = { 0.5 })
assertEquals(500L, midPolicy.nextDelayMs())
// With random nearly 1.0 (0.999), 0.999 * 1000 = 999
val maxPolicy = ReconnectPolicy(random = { 0.999 })
assertEquals(999L, maxPolicy.nextDelayMs())
}
@Test
fun nextDelayMsRespectsCeilingCapsBelowFloorAndZero() {
// Ceiling <= 0 results in 0
val zeroPolicy = ReconnectPolicy(random = { 0.5 })
zeroPolicy.currentBackoff = 0L
assertEquals(0L, zeroPolicy.nextDelayMs())
val negativePolicy = ReconnectPolicy(random = { 0.5 })
negativePolicy.currentBackoff = -100L
assertEquals(0L, negativePolicy.nextDelayMs())
// When ceiling is 100 (which is < 300 floor), delay should be capped to ceiling (100)
val lowCeilingPolicy = ReconnectPolicy(random = { 0.0 })
lowCeilingPolicy.currentBackoff = 100L
assertEquals(100L, lowCeilingPolicy.nextDelayMs())
}
@Test
fun advanceDoublesBackoffUpToCapOf30000() {
val policy = ReconnectPolicy()
assertEquals(1000L, policy.currentBackoff)
policy.advance()
assertEquals(2000L, policy.currentBackoff)
policy.advance()
assertEquals(4000L, policy.currentBackoff)
policy.advance()
assertEquals(8000L, policy.currentBackoff)
policy.advance()
assertEquals(16000L, policy.currentBackoff)
// Doubles past 30000 should cap exactly at 30000 (not speculative 15000 or 32000)
policy.advance()
assertEquals(30000L, policy.currentBackoff)
policy.advance()
assertEquals(30000L, policy.currentBackoff)
}
@Test
fun resetRestoresInitialBackoffOf1000() {
val policy = ReconnectPolicy()
policy.advance()
policy.advance()
assertEquals(4000L, policy.currentBackoff)
policy.reset()
assertEquals(1000L, policy.currentBackoff)
}
@Test
fun setBackoffForTestOverridesCurrentAndInitialBackoffDeterministically() {
val policy = ReconnectPolicy(random = { 0.5 })
policy.setBackoffForTest(1500L)
assertEquals(1500L, policy.currentBackoff)
// With test override, delay should be deterministic override value
assertEquals(1500L, policy.nextDelayMs())
// advance() still uses deterministic override if set, or advances from set initial/current
policy.advance()
assertEquals(1500L, policy.nextDelayMs())
// reset() retains deterministic override behavior or resets to overridden initial
policy.reset()
assertEquals(1500L, policy.nextDelayMs())
}
}
@@ -0,0 +1,555 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class ReplayTrackerTest {
private val json = Json { ignoreUnknownKeys = true }
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
@Test
fun `initial state has empty watermarks and no replay in flight`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = {},
)
assertTrue(tracker.getSeqWatermarks().isEmpty())
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `watermarks can be set read and cleared without cancelling jobs`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = {},
)
tracker.setSeqWatermark("s1", 10)
tracker.setSeqWatermark("s2", 25)
assertEquals(mapOf("s1" to 10, "s2" to 25), tracker.getSeqWatermarks())
tracker.clearSeqWatermarks()
assertTrue(tracker.getSeqWatermarks().isEmpty())
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `triggerReplay is no-op when watermarks are empty`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
var fetchCalled = false
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = {
fetchCalled = true
SessionEventsSinceResult()
},
emitEvent = {},
)
tracker.triggerReplay()
assertFalse(tracker.isReplayInFlight())
testScope.advanceUntilIdle()
assertFalse(fetchCalled)
}
@Test
fun `triggerReplay arms hold synchronously and sets replay in flight before coroutine runs`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val deferred = CompletableDeferred<SessionEventsSinceResult>()
val emitted = mutableListOf<WsEvent>()
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { deferred.await() },
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 5)
// Arms hold synchronously
tracker.triggerReplay()
assertTrue(tracker.isReplayInFlight())
// Calling triggerReplay again while in flight is ignored
tracker.triggerReplay()
assertTrue(tracker.isReplayInFlight())
// A live event arriving now for s1 must be parked into hold without advancing watermark
val liveMsg = WsEvent.MessageToken(token = "live", sessionId = "s1")
tracker.acceptLiveEvent("s1", 8, liveMsg)
// Watermark has NOT advanced to 8 because replay is in flight and event is held
assertEquals(5, tracker.getSeqWatermarks()["s1"])
assertTrue(emitted.isEmpty())
// Complete replay
deferred.complete(SessionEventsSinceResult())
testScope.advanceUntilIdle()
// Replay completed, held event released and emitted, watermark advanced
assertFalse(tracker.isReplayInFlight())
assertEquals(listOf(liveMsg), emitted)
assertEquals(8, tracker.getSeqWatermarks()["s1"])
}
@Test
fun `isDuplicate drops equal or older seq and accepts newer seq`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = {},
)
tracker.setSeqWatermark("s1", 10)
// Same seq: duplicate
assertTrue(tracker.isDuplicate("s1", 10))
// Older seq: duplicate
assertTrue(tracker.isDuplicate("s1", 5))
// Newer seq: not duplicate
assertFalse(tracker.isDuplicate("s1", 11))
// Unknown session defaults to seq 0
assertFalse(tracker.isDuplicate("s2", 1))
assertTrue(tracker.isDuplicate("s2", 0))
}
@Test
fun `acceptLiveEvent without in-flight replay emits event and advances watermark`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val emitted = mutableListOf<WsEvent>()
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 10)
val event = WsEvent.MessageToken(token = "tok", sessionId = "s1")
// Duplicate seq: dropped
tracker.acceptLiveEvent("s1", 10, event)
assertTrue(emitted.isEmpty())
assertEquals(10, tracker.getSeqWatermarks()["s1"])
// Newer seq: emitted and updates watermark
tracker.acceptLiveEvent("s1", 12, event)
assertEquals(listOf(event), emitted)
assertEquals(12, tracker.getSeqWatermarks()["s1"])
}
@Test
fun `replay events are emitted in provided order and advance watermarks`() =
runTest {
val emitted = mutableListOf<WsEvent>()
val event1 =
jsonElement(
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"a"}}""",
)
val event2 =
jsonElement(
"""{"type":"message.token","session_id":"s1","seq":7,"payload":{"text":"b"}}""",
)
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
SessionEventsSinceResult(
events = listOf(event1, event2),
)
},
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 5)
tracker.fetchReplayForTest()
assertEquals(2, emitted.size)
assertEquals("a", (emitted[0] as WsEvent.MessageToken).token)
assertEquals("b", (emitted[1] as WsEvent.MessageToken).token)
assertEquals(7, tracker.getSeqWatermarks()["s1"])
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `replay events with seq at or below watermark are dropped`() =
runTest {
val emitted = mutableListOf<WsEvent>()
val oldEvent =
jsonElement(
"""{"type":"message.token","session_id":"s1","seq":5,"payload":{"text":"old"}}""",
)
val newEvent =
jsonElement(
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"new"}}""",
)
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
SessionEventsSinceResult(
events = listOf(oldEvent, newEvent),
)
},
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 5)
tracker.fetchReplayForTest()
assertEquals(1, emitted.size)
assertEquals("new", (emitted[0] as WsEvent.MessageToken).token)
assertEquals(6, tracker.getSeqWatermarks()["s1"])
}
@Test
fun `held live duplicates covered by replay are dropped and fresh held follow`() =
runTest {
val deferred = CompletableDeferred<SessionEventsSinceResult>()
val emitted = mutableListOf<WsEvent>()
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = { deferred.await() },
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 5)
tracker.triggerReplay()
// Live event 6 arrives while replay in flight
val live6 = WsEvent.MessageToken(token = "live6", sessionId = "s1")
val live7 = WsEvent.MessageToken(token = "live7", sessionId = "s1")
tracker.acceptLiveEvent("s1", 6, live6)
tracker.acceptLiveEvent("s1", 7, live7)
// Replay also returns event 6
val replay6 =
jsonElement(
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replay6"}}""",
)
deferred.complete(
SessionEventsSinceResult(
events = listOf(replay6),
),
)
testScheduler.advanceUntilIdle()
// Replay 6 is emitted first, advancing watermark to 6.
// Held live 6 (seq 6 <= 6) is dropped as duplicate.
// Held live 7 (seq 7 > 6) is emitted and advances watermark to 7.
assertEquals(2, emitted.size)
assertEquals("replay6", (emitted[0] as WsEvent.MessageToken).token)
assertEquals("live7", (emitted[1] as WsEvent.MessageToken).token)
assertEquals(7, tracker.getSeqWatermarks()["s1"])
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `epoch change clears all watermarks updates epoch and requests transcript resync`() =
runTest {
val emitted = mutableListOf<WsEvent>()
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
SessionEventsSinceResult(
epoch = "epoch-2",
latestSeq = 50,
)
},
emitEvent = { emitted.add(it) },
)
tracker.setReplayEpochForTest("epoch-1")
tracker.setSeqWatermark("s1", 10)
tracker.setSeqWatermark("s2", 20)
tracker.fetchReplayForTest()
// Watermarks wiped
assertTrue(tracker.getSeqWatermarks().isEmpty())
assertEquals(1, emitted.size)
assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0])
}
@Test
fun `empty or null epoch in result does not trigger epoch change`() =
runTest {
val emitted = mutableListOf<WsEvent>()
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
SessionEventsSinceResult(
epoch = "",
events = emptyList(),
)
},
emitEvent = { emitted.add(it) },
)
tracker.setReplayEpochForTest("epoch-1")
tracker.setSeqWatermark("s1", 10)
tracker.fetchReplayForTest()
assertEquals(10, tracker.getSeqWatermarks()["s1"])
assertTrue(emitted.isEmpty())
}
@Test
fun `truncated resync fast-forwards watermark to latestSeq when epoch is same`() =
runTest {
val emitted = mutableListOf<WsEvent>()
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
SessionEventsSinceResult(
truncated = true,
latestSeq = 100,
epoch = "epoch-1",
)
},
emitEvent = { emitted.add(it) },
)
tracker.setReplayEpochForTest("epoch-1")
tracker.setSeqWatermark("s1", 10)
tracker.fetchReplayForTest()
// Fast-forwards to latestSeq and emits TranscriptResyncRequired
assertEquals(100, tracker.getSeqWatermarks()["s1"])
assertEquals(listOf(WsEvent.TranscriptResyncRequired("s1")), emitted)
}
@Test
fun `replay failure triggers resync invokes onReplayFailure and releases held events`() =
runTest {
val deferred = CompletableDeferred<SessionEventsSinceResult>()
val emitted = mutableListOf<WsEvent>()
val failedSessions = mutableListOf<Pair<String, Exception>>()
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
deferred.await()
},
emitEvent = { emitted.add(it) },
onReplayFailure = { sid, ex -> failedSessions.add(sid to ex) },
)
tracker.setSeqWatermark("s1", 10)
tracker.triggerReplay()
// Park a live event while waiting
val liveEvent = WsEvent.MessageToken(token = "held", sessionId = "s1")
tracker.acceptLiveEvent("s1", 15, liveEvent)
// Fail replay
val testException = IllegalStateException("network error")
deferred.completeExceptionally(testException)
testScheduler.advanceUntilIdle()
// Failure emitted TranscriptResyncRequired, reported to callback, and released held events
assertEquals(1, failedSessions.size)
assertEquals("s1", failedSessions[0].first)
assertTrue(failedSessions[0].second is IllegalStateException)
assertEquals("network error", failedSessions[0].second.message)
assertEquals(2, emitted.size)
assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0])
assertEquals(liveEvent, emitted[1])
assertEquals(15, tracker.getSeqWatermarks()["s1"])
assertFalse(tracker.isReplayInFlight())
}
@Test
fun `MessageComplete event uses resolveStoredSessionId injection`() =
runTest {
val emitted = mutableListOf<WsEvent>()
val msgCompleteJson =
jsonElement(
"""{"type":"message.complete","session_id":"wire-sid","payload":{"text":"done"}}""",
)
val tracker =
ReplayTracker(
scope = this,
fetchEventsSince = {
SessionEventsSinceResult(
events = listOf(msgCompleteJson),
)
},
emitEvent = { emitted.add(it) },
resolveStoredSessionId = { wireSid -> "resolved-$wireSid" },
)
tracker.setSeqWatermark("wire-sid", 5)
tracker.fetchReplayForTest()
assertEquals(1, emitted.size)
val complete = emitted[0] as WsEvent.MessageComplete
assertEquals("done", complete.text)
assertEquals("wire-sid", complete.sessionId)
assertEquals("resolved-wire-sid", complete.storedSessionId)
}
@Test
fun `updateEpoch clears watermarks when epoch changes`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = {},
)
tracker.setReplayEpochForTest("epoch-1")
tracker.setSeqWatermark("s1", 10)
// Same epoch does not clear
tracker.updateEpoch("epoch-1")
assertEquals(10, tracker.getSeqWatermarks()["s1"])
// Empty / null does not clear
tracker.updateEpoch(null)
tracker.updateEpoch("")
assertEquals(10, tracker.getSeqWatermarks()["s1"])
// Different epoch clears watermarks
tracker.updateEpoch("epoch-2")
assertTrue(tracker.getSeqWatermarks().isEmpty())
}
@Test
fun `clear cancels in-flight job and resets all state`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val deferred = CompletableDeferred<SessionEventsSinceResult>()
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { deferred.await() },
emitEvent = {},
)
tracker.setSeqWatermark("s1", 10)
tracker.triggerReplay()
assertTrue(tracker.isReplayInFlight())
tracker.clear()
assertFalse(tracker.isReplayInFlight())
assertTrue(tracker.getSeqWatermarks().isEmpty())
}
@Test
fun `acceptLiveEvent with eventFactory drops duplicate seq without invoking factory or emitting`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val emitted = mutableListOf<WsEvent>()
var factoryInvoked = false
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 10)
// Duplicate seq <= watermark must not invoke factory nor emit
tracker.acceptLiveEvent(sessionId = "s1", seq = 10) {
factoryInvoked = true
WsEvent.MessageToken(token = "duplicate", sessionId = "s1")
}
assertFalse(factoryInvoked)
assertTrue(emitted.isEmpty())
assertEquals(10, tracker.getSeqWatermarks()["s1"])
}
@Test
fun `acceptLiveEvent with eventFactory invokes factory once and preserves single-check ordering`() {
val testDispatcher = StandardTestDispatcher()
val testScope = TestScope(testDispatcher)
val emitted = mutableListOf<WsEvent>()
val invocationCount = AtomicInteger(0)
val tracker =
ReplayTracker(
scope = testScope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { emitted.add(it) },
)
tracker.setSeqWatermark("s1", 10)
val expectedEvent = WsEvent.MessageToken(token = "fresh", sessionId = "s1")
// Factory intentionally injects an interleaved watermark advance (e.g. simulating race or side effect).
// Under single-check ordering, the initial pre-parse check (seq 11 > 10) succeeds, calls factory exactly once,
// and proceeds to emit and advance watermark without silently dropping due to an unexpected second check.
tracker.acceptLiveEvent(sessionId = "s1", seq = 11) {
invocationCount.incrementAndGet()
tracker.setSeqWatermark("s1", 99)
expectedEvent
}
assertEquals(1, invocationCount.get())
assertEquals(listOf(expectedEvent), emitted)
assertEquals(11, tracker.getSeqWatermarks()["s1"])
}
}
@@ -0,0 +1,790 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class RpcChannelTest {
private val testJson =
Json {
ignoreUnknownKeys = true
encodeDefaults = false
}
@Serializable
private data class SampleRequestParams(
val message: String,
val count: Int? = null,
)
@Serializable
private data class SampleResult(
val status: String,
val total: Int,
)
private val sampleMethod =
RpcMethod("test.sample", SampleRequestParams.serializer(), SampleResult.serializer())
private class FakeGateway(
scope: CoroutineScope,
json: Json,
outboundLock: Any = Any(),
val onResultCallback: (String) -> Unit = {},
val onErrorCallback: (String) -> Unit = {},
resolveStoredSessionId: (String?) -> String? = { it },
val logParseFailureCallback: (Exception) -> Unit = {},
decorateParams: (String, Map<String, Any>) -> Map<String, Any> = { _, p -> p },
) {
val sentRequests = mutableListOf<SentRequestRecord>()
val sentFrames = mutableListOf<String>()
val removedQueuedIds = mutableListOf<String>()
val rejectedLogs = mutableListOf<Pair<String, String>>()
var idleCheckCount = 0
var sendFrameReturnValue = true
var immediateResolveOnSend: ((id: String, onSent: ((String) -> Unit)?) -> Unit)? = null
val channel: RpcChannel =
RpcChannel(
scope = scope,
json = json,
sendRequest = { method, params, onSent ->
val id = "req-${sentRequests.size + 1}"
sentRequests.add(SentRequestRecord(id, method, params))
onSent?.invoke(id)
immediateResolveOnSend?.invoke(id, onSent)
id
},
sendFrame = { frame ->
sentFrames.add(frame)
sendFrameReturnValue
},
removeQueuedMessage = { id ->
removedQueuedIds.add(id)
},
onIdleCheck = {
idleCheckCount++
},
logRejected = { method, id ->
rejectedLogs.add(method to id)
},
outboundLock = outboundLock,
onResult = onResultCallback,
onError = onErrorCallback,
resolveStoredSessionId = resolveStoredSessionId,
logParseFailure = logParseFailureCallback,
decorateParams = decorateParams,
)
data class SentRequestRecord(
val id: String,
val method: String,
val params: Map<String, Any>,
)
}
@Test
fun nextIdProducesIncreasingNonBlankStrings() {
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
val firstId = gateway.channel.nextId()
val secondId = gateway.channel.nextId()
assertEquals("1", firstId)
assertTrue(firstId.isNotBlank())
assertTrue(secondId.isNotBlank())
val firstNum = firstId.toLong()
val secondNum = secondId.toLong()
assertTrue(secondNum > firstNum)
assertEquals(firstNum + 1L, secondNum)
}
@Test
fun requestSuccessResolvesDeferredAndCleansUpInOrder() =
runTest {
val gateway = FakeGateway(this, testJson)
val deferred = gateway.channel.request("user.ping", mapOf("key" to "value"))
val id = gateway.sentRequests.first().id
assertTrue(gateway.channel.hasPendingCalls())
assertTrue(gateway.channel.pendingCallIdsForTest().contains(id))
assertNotNull(gateway.channel.pendingCallTimeoutForTest(id))
val dummyResult = JsonPrimitive("pong")
gateway.channel.resolvePending(id, dummyResult, null)
val resolved = deferred.await()
assertEquals(dummyResult, resolved)
assertFalse(gateway.channel.hasPendingCalls())
assertEquals(listOf(id), gateway.removedQueuedIds)
assertEquals(1, gateway.idleCheckCount)
assertNull(gateway.channel.pendingCallTimeoutForTest(id))
}
@Test
fun requestErrorResolvesWithHermesRpcExceptionAndErrorData() =
runTest {
val gateway = FakeGateway(this, testJson)
val deferred = gateway.channel.request("user.fail")
val id = gateway.sentRequests.first().id
val errorData = buildJsonObject { put("reason", "auth_failed") }
val rpcError = JsonRpcError(code = 401, message = "Unauthorized", data = errorData)
gateway.channel.resolvePending(id, null, rpcError)
try {
deferred.await()
fail("Expected HermesRpcException")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals("Unauthorized", e.message)
assertEquals(401, e.code)
assertEquals(errorData, e.data)
}
assertFalse(gateway.channel.hasPendingCalls())
assertEquals(listOf(id), gateway.removedQueuedIds)
assertEquals(1, gateway.idleCheckCount)
}
@Test
fun requestTimeoutFailsWithExactMessageAndCodeMinusOne() =
runTest {
val gateway = FakeGateway(this, testJson)
val timeoutMs = 5_000L
val deferred = gateway.channel.request("agent.turn", timeoutMs = timeoutMs)
val id = gateway.sentRequests.first().id
runCurrent()
advanceTimeBy(4_999L)
runCurrent()
assertTrue(gateway.channel.hasPendingCalls())
assertFalse(deferred.isCompleted)
advanceTimeBy(1L)
runCurrent()
assertTrue(deferred.isCompleted)
assertFalse(gateway.channel.hasPendingCalls())
assertEquals(listOf(id), gateway.removedQueuedIds)
assertEquals(1, gateway.idleCheckCount)
try {
deferred.await()
fail("Expected HermesRpcException on timeout")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals("Request timed out: agent.turn", e.message)
assertEquals(-1, e.code)
assertNull(e.data)
}
}
@Test
fun deferredCancellationCancelsTimerRemovesQueuedAndTriggersIdle() =
runTest {
val gateway = FakeGateway(this, testJson)
val deferred = gateway.channel.request("slow.method", timeoutMs = 60_000L)
val id = gateway.sentRequests.first().id
val timeoutJob = gateway.channel.pendingCallTimeoutForTest(id)
assertNotNull(timeoutJob)
assertFalse(timeoutJob!!.isCancelled)
deferred.cancel()
runCurrent()
assertTrue(deferred.isCancelled)
assertFalse(gateway.channel.hasPendingCalls())
assertTrue(timeoutJob.isCancelled)
assertEquals(listOf(id), gateway.removedQueuedIds)
assertEquals(1, gateway.idleCheckCount)
// Advancing past original timeout should do nothing further
advanceTimeBy(70_000L)
runCurrent()
assertEquals(1, gateway.idleCheckCount)
}
@Test
fun suppressionFlagReflectedInIsErrorEventSuppressed() =
runTest {
val gateway = FakeGateway(this, testJson)
gateway.channel.request("normal.call", suppressErrorEvent = false)
val normalId = gateway.sentRequests[0].id
gateway.channel.request("silent.call", suppressErrorEvent = true)
val silentId = gateway.sentRequests[1].id
assertFalse(gateway.channel.isErrorEventSuppressed(normalId))
assertTrue(gateway.channel.isErrorEventSuppressed(silentId))
assertFalse(gateway.channel.isErrorEventSuppressed("non-existent-id"))
}
@Test
fun rejectAllPendingFailsAllCallsWithProvidedExceptionAndNoIdleCheck() =
runTest {
val gateway = FakeGateway(this, testJson)
val deferred1 = gateway.channel.request("method.one")
val deferred2 = gateway.channel.request("method.two")
val id1 = gateway.sentRequests[0].id
val id2 = gateway.sentRequests[1].id
val customException = HermesWsClient.HermesRpcException("Socket terminated abruptly", code = 1006)
gateway.channel.rejectAllPending(customException)
assertFalse(gateway.channel.hasPendingCalls())
// Verified contract: rejectAllPending performs NO onIdleCheck
assertEquals(0, gateway.idleCheckCount)
assertEquals(setOf(id1, id2), gateway.removedQueuedIds.toSet())
assertEquals(setOf("method.one" to id1, "method.two" to id2), gateway.rejectedLogs.toSet())
try {
deferred1.await()
fail("deferred1 should have failed")
} catch (e: HermesWsClient.HermesRpcException) {
assertSame(customException, e)
}
try {
deferred2.await()
fail("deferred2 should have failed")
} catch (e: HermesWsClient.HermesRpcException) {
assertSame(customException, e)
}
}
@Test
fun rejectAllPendingDefaultExceptionMatchesExpectedMessage() =
runTest {
val gateway = FakeGateway(this, testJson)
val deferred = gateway.channel.request("some.call")
gateway.channel.rejectAllPending()
try {
deferred.await()
fail("Expected default exception")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals("Connection lost — request cancelled", e.message)
}
}
@Test
fun immediateResolutionDuringOnSentHandledSafely() =
runTest {
val gateway = FakeGateway(this, testJson)
gateway.immediateResolveOnSend = { id, _ ->
gateway.channel.resolvePending(id, "instant-result", null)
}
val deferred = gateway.channel.request("instant.call")
val id = gateway.sentRequests.first().id
assertTrue(deferred.isCompleted)
assertEquals("instant-result", deferred.await())
assertFalse(gateway.channel.hasPendingCalls())
assertEquals(listOf(id), gateway.removedQueuedIds)
assertEquals(1, gateway.idleCheckCount)
}
@Test
fun typedCallSerializesParamsOmittingDefaultsAndDecodesResult() =
runTest {
val gateway = FakeGateway(this, testJson)
val job =
async {
gateway.channel.call(
sampleMethod,
SampleRequestParams(message = "hello", count = null),
)
}
runCurrent()
assertEquals(1, gateway.sentRequests.size)
val sent = gateway.sentRequests.first()
assertEquals("test.sample", sent.method)
// encodeDefaults = false means count (null) is omitted from params
val encodedParams =
gateway.channel.encodeParams(
sampleMethod,
SampleRequestParams(message = "hello", count = null),
)
assertFalse(encodedParams.containsKey("count"))
assertTrue(encodedParams.containsKey("message"))
// Resolve pending with raw json representation
val rawResult =
buildJsonObject {
put("status", "ok")
put("total", 42)
put("extra_ignored_key", "bonus")
}
gateway.channel.resolvePending(sent.id, rawResult, null)
val result = job.await()
assertEquals("ok", result.status)
assertEquals(42, result.total)
}
@Test
fun typedCallCancellationPropagatesAndCancelsDeferred() =
runTest {
val gateway = FakeGateway(this, testJson)
val job =
launch {
gateway.channel.call(
sampleMethod,
SampleRequestParams(message = "cancel-me"),
)
}
runCurrent()
val id = gateway.sentRequests.first().id
assertTrue(gateway.channel.hasPendingCalls())
job.cancel()
runCurrent()
assertFalse(gateway.channel.hasPendingCalls())
assertEquals(listOf(id), gateway.removedQueuedIds)
assertEquals(1, gateway.idleCheckCount)
}
@Test
fun rawJsonNumericFidelityPreservedIntoResolve() =
runTest {
val gateway = FakeGateway(this, testJson)
val deferred =
gateway.channel.requestTyped(
sampleMethod,
SampleRequestParams(message = "precision"),
)
val id = gateway.sentRequests.first().id
// Map with integer-value double, e.g. 177.0 -> preserves int semantics in toJsonElement
val mapResult = mapOf("status" to "precise", "total" to 177.0)
gateway.channel.resolvePending(id, mapResult, null)
val raw = deferred.await()
val jsonElement = raw.toJsonElement() as JsonObject
assertEquals(177, jsonElement["total"]?.jsonPrimitive?.int)
// Direct decode with injected channel json to verify round-trip
val decoded = testJson.decodeFromJsonElement(sampleMethod.result, jsonElement)
assertEquals(177, decoded.total)
assertEquals("precise", decoded.status)
}
@Test
fun respondToServerRequestFormatsExactJsonRpcResponseAndPropagatesSink() {
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
val serverReqId = "srv-999"
val payload = buildJsonObject { put("pong", true) }
gateway.sendFrameReturnValue = true
val result = gateway.channel.respondToServerRequest(serverReqId, payload)
assertTrue(result)
assertEquals(1, gateway.sentFrames.size)
val frame = testJson.decodeFromString<JsonObject>(gateway.sentFrames.first())
assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content)
assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content)
assertEquals(payload, frame["result"])
assertFalse(frame.containsKey("method"))
assertFalse(frame.containsKey("params"))
// Blank id returns false immediately without sending frame
val blankResult = gateway.channel.respondToServerRequest(" ", payload)
assertFalse(blankResult)
assertEquals(1, gateway.sentFrames.size)
// Sink returning false is propagated
gateway.sendFrameReturnValue = false
val falseResult = gateway.channel.respondToServerRequest("srv-1000", payload)
assertFalse(falseResult)
assertEquals(2, gateway.sentFrames.size)
}
@Test
fun respondToServerRequestErrorFormatsExactJsonRpcErrorAndPropagatesSink() {
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
val serverReqId = "srv-888"
gateway.sendFrameReturnValue = true
val result = gateway.channel.respondToServerRequestError(serverReqId, -32601, "Method not found")
assertTrue(result)
assertEquals(1, gateway.sentFrames.size)
val frame = testJson.decodeFromString<JsonObject>(gateway.sentFrames.first())
assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content)
assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content)
assertFalse(frame.containsKey("result"))
val errorObj = frame["error"] as JsonObject
assertEquals(-32601, errorObj["code"]?.jsonPrimitive?.int)
assertEquals("Method not found", errorObj["message"]?.jsonPrimitive?.content)
// Blank id returns false without sending frame
assertFalse(gateway.channel.respondToServerRequestError("", -1, "Bad"))
assertEquals(1, gateway.sentFrames.size)
// Sink returning false is propagated
gateway.sendFrameReturnValue = false
val falseResult = gateway.channel.respondToServerRequestError("srv-889", -32000, "Error")
assertFalse(falseResult)
assertEquals(2, gateway.sentFrames.size)
}
@Test
fun createRequestDecoratesOnceAllocatesIdAndEncodesOmittingDefaults() {
var decorateCount = 0
val gateway =
FakeGateway(
scope = CoroutineScope(CompletableDeferred<Unit>()),
json = testJson,
decorateParams = { method, params ->
decorateCount++
params + ("decoratedMethod" to method)
},
)
val request = gateway.channel.createRequest("test.action", mapOf("key" to "value"))
assertEquals("1", request.id)
assertEquals("test.action", request.method)
assertEquals(1, decorateCount)
assertEquals(JsonPrimitive("value"), request.params["key"])
assertEquals(JsonPrimitive("test.action"), request.params["decoratedMethod"])
val encoded = gateway.channel.encodeRequest(request)
// Injected json with encodeDefaults = false omits default jsonrpc ("2.0")
assertFalse(encoded.contains("\"jsonrpc\""))
assertTrue(encoded.contains("\"id\":\"1\"") || encoded.contains("\"id\": \"1\""))
assertTrue(encoded.contains("\"method\":\"test.action\"") || encoded.contains("\"method\": \"test.action\""))
// An empty params map is omitted by encodeRequest under encodeDefaults = false
val plainGateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
val emptyParamsReq = plainGateway.channel.createRequest("test.empty", emptyMap())
val encodedEmpty = plainGateway.channel.encodeRequest(emptyParamsReq)
assertFalse(encodedEmpty.contains("\"params\""))
}
@Test
fun handleIncomingFrameOrderRawJsonResolvedBeforeEventEmittedAndDoubleOnResult() =
runTest {
val onResultCalls = mutableListOf<String>()
val events = mutableListOf<WsEvent>()
val gateway =
FakeGateway(
scope = this,
json = testJson,
onResultCallback = { onResultCalls.add(it) },
)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
val deferred = gateway.channel.request("client.calc")
val id = gateway.sentRequests.first().id
var rawResolvedAtEventTime: Boolean? = null
var onResultCallsAtEventTime = 0
val emitSink: (WsEvent) -> Unit = { event ->
events.add(event)
rawResolvedAtEventTime = deferred.isCompleted
onResultCallsAtEventTime = onResultCalls.size
}
val incomingFrame = """{"jsonrpc":"2.0","id":"$id","result":{"ans":42.0}}"""
gateway.channel.handleIncomingFrame(incomingFrame, replayTracker, emitSink)
// onResult is invoked twice baseline: once before raw resolve, and again before parsed RpcResult resolution
assertEquals(2, onResultCalls.size)
assertEquals(listOf(id, id), onResultCalls)
assertEquals(2, onResultCallsAtEventTime)
// Raw deferred was settled BEFORE emitEvent was called
assertEquals(true, rawResolvedAtEventTime)
assertTrue(deferred.isCompleted)
// Numeric fidelity preserved on result
val rawResult = deferred.await()
assertTrue(rawResult is JsonObject)
val rawObj = rawResult as JsonObject
assertEquals(JsonPrimitive(42.0), rawObj["ans"])
// Event emitted was RpcResult
assertEquals(1, events.size)
val rpcResult = events.first() as WsEvent.RpcResult
assertEquals(id, rpcResult.id)
}
@Test
fun handleIncomingFrameRpcErrorInvokesCallbackAndRespectsSuppression() =
runTest {
val onErrorCalls = mutableListOf<String>()
val events = mutableListOf<WsEvent>()
val gateway =
FakeGateway(
scope = this,
json = testJson,
onErrorCallback = { onErrorCalls.add(it) },
)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
// 1. Normal unsuppressed error
val normalDeferred = gateway.channel.request("call.normal", suppressErrorEvent = false)
val normalId = gateway.sentRequests[0].id
val errorFrame1 =
"""{"jsonrpc":"2.0","id":"$normalId","error":{"code":-32603,"message":"Internal error"}}"""
gateway.channel.handleIncomingFrame(errorFrame1, replayTracker) { events.add(it) }
assertEquals(listOf(normalId), onErrorCalls)
assertTrue(normalDeferred.isCompleted)
try {
normalDeferred.await()
fail("Expected HermesRpcException")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals(-32603, e.code)
assertEquals("Internal error", e.message)
}
assertEquals(1, events.size)
assertTrue(events.first() is WsEvent.RpcError)
assertEquals(normalId, (events.first() as WsEvent.RpcError).id)
// 2. Suppressed error resolves deferred but emits NONE
val silentDeferred = gateway.channel.request("call.silent", suppressErrorEvent = true)
val silentId = gateway.sentRequests[1].id
val errorFrame2 =
"""{"jsonrpc":"2.0","id":"$silentId","error":{"code":404,"message":"Not found"}}"""
gateway.channel.handleIncomingFrame(errorFrame2, replayTracker) { events.add(it) }
assertEquals(listOf(normalId, silentId), onErrorCalls)
assertTrue(silentDeferred.isCompleted)
try {
silentDeferred.await()
fail("Expected HermesRpcException on silent deferred")
} catch (e: HermesWsClient.HermesRpcException) {
assertEquals(404, e.code)
}
// Event list size remains 1 (no RpcError event emitted for suppressed call)
assertEquals(1, events.size)
}
@Test
fun handleIncomingFrameCapabilitySuppressionHidesEventUntilCleared() =
runTest {
val events = mutableListOf<WsEvent>()
val gateway = FakeGateway(this, testJson)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
val capId = "cap-42"
gateway.channel.registerCapabilityRequest(capId)
val capResponseFrame = """{"jsonrpc":"2.0","id":"$capId","result":{"supported":true}}"""
gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) }
// Registered capability response is hidden from events
assertTrue(events.isEmpty())
// After clearing capabilities, subsequent responses with that ID are visible
gateway.channel.registerCapabilityRequest(capId)
gateway.channel.clearCapabilityRequests()
gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) }
assertEquals(1, events.size)
val rpcResult = events.first() as WsEvent.RpcResult
assertEquals(capId, rpcResult.id)
}
@Test
fun handleIncomingFrameMalformedJsonInvokesParseFailureAndEmitsUnknown() =
runTest {
val parseFailures = mutableListOf<Exception>()
val events = mutableListOf<WsEvent>()
val gateway =
FakeGateway(
scope = this,
json = testJson,
logParseFailureCallback = { parseFailures.add(it) },
)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
val malformed = "{ invalid json frame ::"
gateway.channel.handleIncomingFrame(malformed, replayTracker) { events.add(it) }
assertEquals(1, parseFailures.size)
assertEquals(1, events.size)
val unknown = events.first() as WsEvent.Unknown
assertEquals(malformed, unknown.raw)
}
@Test
fun handleIncomingFrameServerRequestClassifiedCorrectlyOverRpcResult() =
runTest {
val events = mutableListOf<WsEvent>()
val gateway = FakeGateway(this, testJson)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
// Normal srq frame with id + method should emit ServerRequest, not RpcResult
val srqFrame = """{"jsonrpc":"2.0","id":"srq-101","method":"clarify","params":{"text":"Confirm?"}}"""
gateway.channel.handleIncomingFrame(srqFrame, replayTracker) { events.add(it) }
assertEquals(1, events.size)
val event = events.first()
assertTrue("Expected ServerRequest, got: $event", event is WsEvent.ServerRequest)
val srq = event as WsEvent.ServerRequest
assertEquals("srq-101", srq.id)
assertEquals("clarify", srq.method)
assertFalse(srq.replayed)
}
@Test
fun handleIncomingFrameSequencedNotificationDeduplicatesViaReplayTracker() =
runTest {
val events = mutableListOf<WsEvent>()
val gateway = FakeGateway(this, testJson)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
val seqFrame =
"""{"method":"events","params":{"type":"message.token","session_id":"s1","seq":11,"payload":{"text":"fresh"}}}"""
// First delivery (fresh seq) emits once
gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) }
assertEquals(1, events.size)
val tokenEvent = events.first() as WsEvent.MessageToken
assertEquals("fresh", tokenEvent.token)
assertEquals("s1", tokenEvent.sessionId)
// Duplicate delivery (same seq 11) is dropped, emits none
gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) }
assertEquals(1, events.size)
}
@Test
fun handleIncomingFrameSettlesRawResultBeforeReplayingOpenRequests() =
runTest {
val order = mutableListOf<String>()
val gateway = FakeGateway(this, testJson, onResultCallback = { order.add("result:$it") })
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = {},
)
val deferred = gateway.channel.request("session.resume")
val id = gateway.sentRequests.first().id
deferred.invokeOnCompletion { order.add("resolved") }
val frame =
"""{"id":"$id","result":{"open_requests":[{"id":"srq-open","method":"clarify","params":{"text":"replayed"}}]}}"""
gateway.channel.handleIncomingFrame(frame, replayTracker) { event ->
assertTrue("Raw deferred must settle before either event", deferred.isCompleted)
when (event) {
is WsEvent.ServerRequest -> {
assertTrue(event.replayed)
assertEquals("clarify", event.method)
assertEquals("replayed", event.params["text"])
order.add("server:${event.id}")
}
is WsEvent.RpcResult -> {
order.add("rpc:${event.id}")
}
else -> {
fail("Unexpected event: $event")
}
}
}
assertEquals(listOf("result:$id", "resolved", "server:srq-open", "result:$id", "rpc:$id"), order)
assertTrue(deferred.await() is JsonObject)
assertFalse(gateway.channel.hasPendingCalls())
}
@Test
fun handleIncomingFrameDecoratesStoredSessionIdOnMessageComplete() =
runTest {
val events = mutableListOf<WsEvent>()
val gateway =
FakeGateway(
scope = this,
json = testJson,
resolveStoredSessionId = { sid -> "stored-$sid" },
)
val replayTracker =
ReplayTracker(
scope = this,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { events.add(it) },
)
val completeFrame =
"""{"method":"events","params":{"type":"message.complete","session_id":"active-session","payload":{"text":"done","reasoning":"reason"}}}"""
gateway.channel.handleIncomingFrame(completeFrame, replayTracker) { events.add(it) }
assertEquals(1, events.size)
val completeEvent = events.first() as WsEvent.MessageComplete
assertEquals("done", completeEvent.text)
assertEquals("reason", completeEvent.reasoning)
assertEquals("active-session", completeEvent.sessionId)
assertEquals("stored-active-session", completeEvent.storedSessionId)
}
}
@@ -121,14 +121,12 @@ class SessionLiveStatusSourceTest {
@Test
fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() =
runBlocking {
var capturedMethod: String? = null
var capturedParams: Map<String, Any>? = null
var rpcCalls = 0
val source =
HermesSessionLiveStatusSource(
rpcRequest = { method, params ->
capturedMethod = method
capturedParams = params
rpcRequest = {
rpcCalls++
mapOf(
"sessions" to
listOf(
@@ -146,8 +144,8 @@ class SessionLiveStatusSourceTest {
val snapshot = source.fetchActiveSessionsSnapshot()
assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod)
assertTrue(capturedParams!!.isEmpty())
// Method name and empty params are pinned on the wire in HermesWsClientApprovalsTest.
assertEquals(1, rpcCalls)
assertNotNull(snapshot)
assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"])
}
@@ -157,7 +155,7 @@ class SessionLiveStatusSourceTest {
runBlocking {
val source =
HermesSessionLiveStatusSource(
rpcRequest = { _, _ -> throw RuntimeException("RPC error") },
rpcRequest = { throw RuntimeException("RPC error") },
eventsProvider = { MutableSharedFlow() },
connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) },
)
@@ -3,6 +3,18 @@ package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.SubagentListItem
import com.m57.hermescontrol.data.model.SubagentListResponse
import com.m57.hermescontrol.data.model.SubagentTailResponse
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import com.m57.hermescontrol.data.ws.contract.SubagentTailParams
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.runBlocking
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.assertNotNull
@@ -11,6 +23,48 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class SubagentRepositoryTest {
@After
fun tearDown() {
unmockkAll()
}
@Test
fun listSubagentsPreservesSuppressedErrorsAndDecodesResult() {
val captured = slot<SessionIdParams>()
mockkObject(HermesWsClient)
coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_LIST, capture(captured), any(), true) } returns
buildJsonObject { put("subagents", kotlinx.serialization.json.buildJsonArray { }) }
val response = runBlocking { SubagentRepository.listSubagents("s1") }
assertEquals(SessionIdParams("s1"), captured.captured)
assertNotNull(response)
assertTrue(response?.subagents?.isEmpty() == true)
}
@Test
fun tailSubagentSendsExactlySessionIdAndSubagentId() {
// #1379: the contract forbids extra keys (max_bytes) and requires session_id.
val captured = slot<SubagentTailParams>()
mockkObject(HermesWsClient)
coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, capture(captured), any(), true) } returns
buildJsonObject { put("subagent_id", "sub-1") }
runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") }
assertEquals(SubagentTailParams("s1", "sub-1"), captured.captured)
}
@Test
fun tailSubagentSkipsTheCallWhenAnIdIsBlank() {
mockkObject(HermesWsClient)
runBlocking {
assertNull(SubagentRepository.tailSubagent("", "sub-1"))
assertNull(SubagentRepository.tailSubagent("s1", " "))
}
coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, any(), any(), any()) }
}
@Test
fun testWsMethodsConstants() {
assertEquals("subagent.list", WsMethods.SUBAGENT_LIST)
@@ -0,0 +1,489 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.Response
import okhttp3.WebSocket
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.Assert.fail
import org.junit.Before
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
@OptIn(ExperimentalCoroutinesApi::class)
class WsTransportTest {
private val testJson =
Json {
ignoreUnknownKeys = true
encodeDefaults = false
}
private class Fixture(
val scope: CoroutineScope,
json: Json,
nowMs: () -> Long = {
if (scope is TestScope) {
scope.testScheduler.currentTime
} else {
System.nanoTime() / 1_000_000L
}
},
) {
val outboundLock = Any()
val rawMessages = mutableListOf<String>()
val replayEmittedEvents = mutableListOf<WsEvent>()
val reconnectPolicy = ReconnectPolicy()
val rpcSentFrames = mutableListOf<String>()
val rpcRemovedQueueIds = mutableListOf<String>()
var rpcIdleChecks = 0
var transportRef: WsTransport? = null
private val rpcIdCounter = AtomicInteger(1)
val connectionHealth =
ConnectionHealth(
scope = scope,
isConnected = { transportRef?.isConnected ?: false },
pingRequest = { /* ping fake unit */ },
cancelSocket = { transportRef?.cancelSocket() },
pendingReply = { transportRef?.pendingReply ?: false },
nowMs = nowMs,
)
val replayTracker =
ReplayTracker(
scope = scope,
fetchEventsSince = { SessionEventsSinceResult() },
emitEvent = { replayEmittedEvents.add(it) },
)
val rpcChannel =
RpcChannel(
scope = scope,
json = json,
sendRequest = { method, params, onSent ->
val id = "rpc-${rpcIdCounter.getAndIncrement()}"
val req =
rpcChannelRef?.createRequest(method, params)?.copy(id = id)
?: JsonRpcRequest(id = id, method = method)
transportRef?.send(req, onSent) ?: id
},
sendFrame = { frame ->
rpcSentFrames.add(frame)
transportRef?.sendDirectFrame(frame) ?: false
},
removeQueuedMessage = { id ->
rpcRemovedQueueIds.add(id)
transportRef?.removeQueuedMessage(id)
},
onIdleCheck = {
rpcIdleChecks++
transportRef?.disconnectIfIdleInBackground()
},
outboundLock = outboundLock,
onResult = { id -> transportRef?.onRpcResult(id) },
onError = { id -> transportRef?.onRpcError(id) },
).also { rpcChannelRef = it }
val transport =
WsTransport(
scope = scope,
outboundLock = outboundLock,
reconnectPolicy = reconnectPolicy,
connectionHealth = connectionHealth,
replayTracker = replayTracker,
rpcChannel = rpcChannel,
onRawMessage = { rawMessages.add(it) },
).also { transportRef = it }
companion object {
private var rpcChannelRef: RpcChannel? = null
}
}
@Before
fun setUp() {
mockkStatic(Log::class)
every { Log.d(any<String>(), any<String>()) } returns 0
every { Log.i(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>()) } returns 0
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
every { Log.e(any<String>(), any<String>(), any()) } returns 0
ActiveSessionHolder.clear()
mockkObject(AuthManager)
every { AuthManager.isAutoReconnect() } returns false
mockkObject(NetworkMonitor)
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
}
@After
fun tearDown() {
ActiveSessionHolder.clear()
unmockkAll()
}
@Test
fun sendAcceptCallbackRunsUnderOutboundLockAfterSocketAccept() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
fixture.transport.connectedForTest.set(true)
fixture.transport.webSocketForTest = socket
var callbackRan = false
var callbackThreadHoldsLock = false
val request = JsonRpcRequest(id = "req-1", method = "session.list")
val id =
fixture.transport.send(
request = request,
onSent = {
callbackRan = true
callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock)
},
)
assertEquals("req-1", id)
assertTrue(callbackRan)
assertTrue(callbackThreadHoldsLock)
verify(exactly = 1) { socket.send(any<String>()) }
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
}
@Test
fun rejectedRetryableSendEnqueuesAndCancelDoesNotAutoRedialWhenAutoReconnectDisabled() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns false
every { socket.queueSize() } returns 0L
fixture.transport.connectedForTest.set(true)
fixture.transport.intentionalCloseForTest.set(false)
fixture.transport.connectionGenerationForTest.set(1)
fixture.transport.webSocketForTest = socket
var onSentCalled = false
var callbackThreadHoldsLock = false
val request = JsonRpcRequest(id = "req-queued", method = "prompt.submit")
fixture.transport.send(
request = request,
onSent = {
onSentCalled = true
callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock)
},
queueIfDisconnected = true,
)
// When socket rejects, request is pushed back into retryable queue
val queue = fixture.transport.messageQueueForTest
assertEquals(1, queue.size)
assertTrue(queue.peek()?.contains("req-queued") == true)
assertTrue(onSentCalled)
assertTrue(callbackThreadHoldsLock)
assertFalse(fixture.transport.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
assertNull(fixture.transport.reconnectJobForTest)
verify(exactly = 1) { socket.cancel() }
}
@Test
fun staleOnMessageIgnoredWhileCurrentGenerationFrameUpdatesHealthAndRawCallback() =
runTest {
val fixture =
Fixture(
scope = this,
json = testJson,
nowMs = { testScheduler.currentTime },
)
val currentSocket = mockk<WebSocket>(relaxed = true)
val staleSocket = mockk<WebSocket>(relaxed = true)
fixture.transport.connectedForTest.set(true)
fixture.transport.connectionGenerationForTest.set(2)
fixture.transport.webSocketForTest = currentSocket
val initialTimestamp = fixture.connectionHealth.lastPongTimestamp
assertEquals(0L, initialTimestamp)
val staleListener = fixture.transport.createListenerForTest(1)
val currentListener = fixture.transport.createListenerForTest(2)
advanceTimeBy(1_000L)
// Message from stale listener/generation should be discarded
staleListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old\"}")
staleListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old-mismatched-gen\"}")
assertTrue(fixture.rawMessages.isEmpty())
assertEquals(0L, fixture.connectionHealth.lastPongTimestamp)
advanceTimeBy(1_000L)
// Message from current generation but mismatched socket should be discarded
currentListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"wrong-socket\"}")
assertTrue(fixture.rawMessages.isEmpty())
assertEquals(0L, fixture.connectionHealth.lastPongTimestamp)
advanceTimeBy(1_000L)
val expectedTimestamp = testScheduler.currentTime
// Matching generation and matching active socket passes through and updates health
currentListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}")
assertEquals(listOf("{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}"), fixture.rawMessages)
assertEquals(expectedTimestamp, fixture.connectionHealth.lastPongTimestamp)
}
@Test
fun staleOnOpenClosesSupersededWithoutChangingOwnershipOrFlushingQueue() =
runTest {
val fixture = Fixture(this, testJson)
val staleSocket = mockk<WebSocket>(relaxed = true)
val currentSocket = mockk<WebSocket>(relaxed = true)
val response = mockk<Response>(relaxed = true)
fixture.transport.intentionalCloseForTest.set(false)
fixture.transport.connectionGenerationForTest.set(2)
fixture.transport.webSocketForTest = currentSocket
fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"queued-1\"}")
val staleListener = fixture.transport.createListenerForTest(1)
staleListener.onOpen(staleSocket, response)
// Stale onOpen must close the stale socket with 1000 ("Superseded")
verify(exactly = 1) { staleSocket.close(1000, "Superseded") }
// Must not change active socket or flush the queue on stale socket
verify(exactly = 0) { staleSocket.send(any<String>()) }
assertEquals(currentSocket, fixture.transport.webSocketForTest)
assertEquals(1, fixture.transport.messageQueueForTest.size)
}
@Test
fun authClosingCodeSetsTerminalAuthExpiredAndDoesNotAutoRedial() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
fixture.transport.connectedForTest.set(true)
fixture.transport.intentionalCloseForTest.set(false)
fixture.transport.connectionGenerationForTest.set(1)
fixture.transport.webSocketForTest = socket
// Register a pending RPC call to verify it is NOT newly rejected
val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap())
assertFalse(pendingDeferred.isCompleted)
val listener = fixture.transport.createListenerForTest(1)
listener.onClosing(socket, 4401, "unauthorized")
assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value)
assertTrue(fixture.transport.isConnected)
assertNull(fixture.transport.reconnectJobForTest)
assertFalse(pendingDeferred.isCompleted)
listener.onClosed(socket, 4401, "unauthorized")
assertFalse(fixture.transport.isConnected)
assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value)
assertNull(fixture.transport.reconnectJobForTest)
assertFalse(pendingDeferred.isCompleted)
pendingDeferred.cancel()
}
@Test
fun disconnectPreservesPendingCallsWhileClearFlagControlsQueueAndLeaseCleanup() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
fixture.transport.connectedForTest.set(true)
fixture.transport.intentionalCloseForTest.set(false)
fixture.transport.webSocketForTest = socket
// Register actual pending RPC call over socket accepting send
val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap())
assertFalse(pendingDeferred.isCompleted)
// Populate retryable message queue
fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\"}")
// Ordinary disconnect preserves queued messages and does NOT reject pending calls
fixture.transport.disconnect(clearPendingMessages = false)
assertEquals(1, fixture.transport.messageQueueForTest.size)
assertFalse(fixture.transport.isConnected)
assertFalse(pendingDeferred.isCompleted)
// Disconnect with clearPendingMessages = true clears queue and resets leases, still not rejecting pending calls
fixture.transport.acquireBackgroundConnectionLease()
assertTrue(fixture.transport.hasBackgroundConnectionLease())
fixture.transport.disconnect(clearPendingMessages = true)
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
assertFalse(fixture.transport.hasBackgroundConnectionLease())
assertFalse(pendingDeferred.isCompleted)
pendingDeferred.cancel()
}
@Test
fun reconnectForNetworkChangeRejectsPendingCallsAndExecutesReplacementCallbackOnce() =
runTest {
every { AuthManager.isAutoReconnect() } returns true
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send(any<String>()) } returns true
fixture.transport.connectedForTest.set(true)
fixture.transport.intentionalCloseForTest.set(false)
fixture.transport.connectionGenerationForTest.set(1)
fixture.transport.webSocketForTest = socket
// Register non-empty pending RPC while socket accepts send
val pendingDeferred = fixture.rpcChannel.request("custom.network_call", emptyMap())
assertFalse(pendingDeferred.isCompleted)
var replacementOpens = 0
fixture.transport.reconnectForNetworkChange(
networkAvailable = true,
openReplacement = { replacementOpens++ },
)
// Outgoing socket canceled once
verify(exactly = 1) { socket.cancel() }
assertEquals(1, replacementOpens)
assertEquals(2, fixture.transport.connectionGenerationForTest.get())
assertFalse(fixture.transport.isConnected)
assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value)
assertNull(fixture.transport.webSocketForTest)
// Network replacement uses the default connection-lost exception, not a timeout.
assertTrue(pendingDeferred.isCompleted)
try {
pendingDeferred.await()
fail("Expected HermesRpcException")
} catch (exception: HermesWsClient.HermesRpcException) {
assertEquals(0, exception.code)
assertEquals("Connection lost — request cancelled", exception.message)
}
}
@Test
fun combinedLeasesSurviveIdleBackgroundUntilBothReleased() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
fixture.transport.connectedForTest.set(true)
fixture.transport.webSocketForTest = socket
fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
fixture.transport.acquireExternalActivityConnectionLease()
fixture.transport.acquireBackgroundConnectionLease()
assertTrue(fixture.transport.hasBackgroundConnectionLease())
// Backgrounding app with both leases active keeps connection alive
fixture.transport.setAppForeground(false)
assertTrue(fixture.transport.isConnected)
// Releasing external lease keeps connection alive due to background lease
fixture.transport.releaseExternalActivityConnectionLease()
assertTrue(fixture.transport.isConnected)
// Releasing background lease releases all leases and disconnects idle background socket
fixture.transport.releaseBackgroundConnectionLease()
assertFalse(fixture.transport.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
}
@Test
fun observeEventMarksPendingReplyStartAndCompleteRespectingIdlePolicy() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
fixture.transport.connectedForTest.set(true)
fixture.transport.webSocketForTest = socket
fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
// When a message start event arrives BEFORE setAppForeground(false)
val startEvent = WsEvent.MessageStart(sessionId = "s1")
fixture.transport.observeEvent(startEvent)
assertTrue(fixture.transport.pendingReply)
// Setting app foreground to false while pending reply is true stays connected
fixture.transport.setAppForeground(false)
assertTrue(fixture.transport.isConnected)
// Idle disconnect check while pending reply is true keeps socket connected
fixture.transport.disconnectIfIdleInBackground()
assertTrue(fixture.transport.isConnected)
// Message complete event clears pending reply and immediately idle disconnects
val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "s1")
fixture.transport.observeEvent(completeEvent)
assertFalse(fixture.transport.pendingReply)
assertFalse(fixture.transport.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
}
@Test
fun sendDirectFrameBypassesQueueAndReturnsFalseWhenDisconnected() =
runTest {
val fixture = Fixture(this, testJson)
val socket = mockk<WebSocket>(relaxed = true)
every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}") } returns true
every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") } returns false
fixture.transport.connectedForTest.set(true)
fixture.transport.webSocketForTest = socket
// 1. Connected with socket accepting send -> returns true, no queue
val connectedResult =
fixture.transport.sendDirectFrame(
"{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}",
)
assertTrue(connectedResult)
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
// 2. Connected with socket rejecting send -> returns false, no queue
val sendFailsResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}")
assertFalse(sendFailsResult)
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
// 3. Disconnected -> returns false, no queue
fixture.transport.connectedForTest.set(false)
fixture.transport.webSocketForTest = null
val disconnectedResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"direct\"}")
assertFalse(disconnectedResult)
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
}
}
@@ -0,0 +1,209 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ApprovalsTypesTest {
@Test
fun approvalPendingParamsEncodesSessionIdExactly() {
val params = ApprovalPendingParams(sessionId = "sess-appr-pending-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalPendingParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-appr-pending-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun approvalReceivedParamsEncodesSessionIdAndRequestId() {
val params =
ApprovalReceivedParams(
sessionId = "sess-appr-rec-1",
requestId = "req-appr-rec-99",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalReceivedParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-appr-rec-1")
put("request_id", "req-appr-rec-99")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "request_id"), (encoded as JsonObject).keys)
}
@Test
fun approvalRespondParamsEncodesOnlySessionIdWhenOptionalsNull() {
val params = ApprovalRespondParams(sessionId = "sess-respond-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-respond-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun approvalRespondParamsEncodesAllFieldsWhenPresent() {
val params =
ApprovalRespondParams(
sessionId = "sess-respond-2",
choice = "allow",
all = true,
requestId = "req-respond-2",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-respond-2")
put("choice", "allow")
put("all", true)
put("request_id", "req-respond-2")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys)
}
@Test
fun approvalRespondParamsEncodesExplicitFalseForAll() {
val params =
ApprovalRespondParams(
sessionId = "sess-respond-3",
choice = "deny",
all = false,
requestId = "req-respond-3",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-respond-3")
put("choice", "deny")
put("all", false)
put("request_id", "req-respond-3")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys)
}
@Test
fun approvalRespondParamsOmitsOnlyNullFields() {
val params =
ApprovalRespondParams(
sessionId = "sess-respond-4",
choice = "allow",
all = null,
requestId = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-respond-4")
put("choice", "allow")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "choice"), (encoded as JsonObject).keys)
}
@Test
fun sessionActiveListParamsEncodesToEmptyJsonObjectWhenCurrentSessionIdNull() {
val params = SessionActiveListParams()
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun sessionActiveListParamsEncodesCurrentSessionIdWhenSet() {
val params = SessionActiveListParams(currentSessionId = "sess-active-curr-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params)
val expected =
buildJsonObject {
put("current_session_id", "sess-active-curr-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("current_session_id"), (encoded as JsonObject).keys)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("approval.pending", RpcMethods.APPROVAL_PENDING.name)
assertEquals("approval.received", RpcMethods.APPROVAL_RECEIVED.name)
assertEquals("approval.respond", RpcMethods.APPROVAL_RESPOND.name)
assertEquals("session.active_list", RpcMethods.SESSION_ACTIVE_LIST.name)
assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_PENDING))
assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RECEIVED))
assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RESPOND))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_ACTIVE_LIST))
}
@Test
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
val arbitraryPayload =
buildJsonObject {
put("status", "pending")
put("count", 3)
put("nested", buildJsonObject { put("detail", "value") })
}
val decodedPending: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.APPROVAL_PENDING.result,
arbitraryPayload,
)
val decodedReceived: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.APPROVAL_RECEIVED.result,
arbitraryPayload,
)
val decodedRespond: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.APPROVAL_RESPOND.result,
arbitraryPayload,
)
val decodedActiveList: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_ACTIVE_LIST.result,
arbitraryPayload,
)
assertEquals(arbitraryPayload, decodedPending)
assertEquals(arbitraryPayload, decodedReceived)
assertEquals(arbitraryPayload, decodedRespond)
assertEquals(arbitraryPayload, decodedActiveList)
}
}
@@ -0,0 +1,333 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BillingVaultTypesTest {
@Test
fun emptyParamsEncodesToEmptyJsonObject() {
val params = EmptyParams
val encoded = OkHttpProvider.json.encodeToJsonElement(EmptyParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun subscriptionPreviewParamsEncodesSubscriptionTypeIdWhenPresent() {
val params = SubscriptionPreviewParams(subscriptionTypeId = "pro-monthly")
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params)
val expected =
buildJsonObject {
put("subscription_type_id", "pro-monthly")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys)
}
@Test
fun subscriptionPreviewParamsOmitsNullSubscriptionTypeId() {
val params = SubscriptionPreviewParams(subscriptionTypeId = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun subscriptionChangeParamsEncodesAllFieldsWhenPresent() {
val params =
SubscriptionChangeParams(
subscriptionTypeId = "tier-2",
cancel = true,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params)
val expected =
buildJsonObject {
put("subscription_type_id", "tier-2")
put("cancel", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys)
}
@Test
fun subscriptionChangeParamsEncodesExplicitFalseForCancel() {
val params =
SubscriptionChangeParams(
subscriptionTypeId = "tier-1",
cancel = false,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params)
val expected =
buildJsonObject {
put("subscription_type_id", "tier-1")
put("cancel", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys)
}
@Test
fun subscriptionChangeParamsOmitsOnlyNullFields() {
val paramsOnlyCancel = SubscriptionChangeParams(subscriptionTypeId = null, cancel = false)
val encodedOnlyCancel =
OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyCancel)
val expectedOnlyCancel =
buildJsonObject {
put("cancel", false)
}
assertEquals(expectedOnlyCancel, encodedOnlyCancel)
assertTrue(encodedOnlyCancel is JsonObject)
assertEquals(setOf("cancel"), (encodedOnlyCancel as JsonObject).keys)
val paramsOnlyType = SubscriptionChangeParams(subscriptionTypeId = "tier-3", cancel = null)
val encodedOnlyType =
OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyType)
val expectedOnlyType =
buildJsonObject {
put("subscription_type_id", "tier-3")
}
assertEquals(expectedOnlyType, encodedOnlyType)
assertTrue(encodedOnlyType is JsonObject)
assertEquals(setOf("subscription_type_id"), (encodedOnlyType as JsonObject).keys)
val paramsEmpty = SubscriptionChangeParams(subscriptionTypeId = null, cancel = null)
val encodedEmpty =
OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsEmpty)
val expectedEmpty = buildJsonObject {}
assertEquals(expectedEmpty, encodedEmpty)
assertTrue(encodedEmpty is JsonObject)
assertTrue((encodedEmpty as JsonObject).isEmpty())
}
@Test
fun subscriptionUpgradeParamsEncodesSubscriptionTypeIdWhenPresent() {
val params = SubscriptionUpgradeParams(subscriptionTypeId = "enterprise-yearly")
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params)
val expected =
buildJsonObject {
put("subscription_type_id", "enterprise-yearly")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys)
}
@Test
fun subscriptionUpgradeParamsOmitsNullSubscriptionTypeId() {
val params = SubscriptionUpgradeParams(subscriptionTypeId = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun vaultSourceSetParamsEncodesAllFieldsWhenPresent() {
val params =
VaultSourceSetParams(
name = "1password",
enabled = true,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params)
val expected =
buildJsonObject {
put("name", "1password")
put("enabled", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys)
}
@Test
fun vaultSourceSetParamsEncodesExplicitFalseForEnabled() {
val params =
VaultSourceSetParams(
name = "bitwarden",
enabled = false,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params)
val expected =
buildJsonObject {
put("name", "bitwarden")
put("enabled", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys)
}
@Test
fun vaultSourceSetParamsOmitsNullFields() {
val params = VaultSourceSetParams(name = "keepass", enabled = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params)
val expected =
buildJsonObject {
put("name", "keepass")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("name"), (encoded as JsonObject).keys)
val paramsEmpty = VaultSourceSetParams(name = null, enabled = null)
val encodedEmpty =
OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), paramsEmpty)
val expectedEmpty = buildJsonObject {}
assertEquals(expectedEmpty, encodedEmpty)
assertTrue(encodedEmpty is JsonObject)
assertTrue((encodedEmpty as JsonObject).isEmpty())
}
@Test
fun vaultUnlockParamsEncodesAllFieldsWhenPresent() {
val params =
VaultUnlockParams(
name = "local-vault",
password = "secret-password",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params)
val expected =
buildJsonObject {
put("name", "local-vault")
put("password", "secret-password")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("name", "password"), (encoded as JsonObject).keys)
}
@Test
fun vaultUnlockParamsOmitsNullFields() {
val params = VaultUnlockParams(name = "local-vault", password = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params)
val expected =
buildJsonObject {
put("name", "local-vault")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("name"), (encoded as JsonObject).keys)
val paramsEmpty = VaultUnlockParams(name = null, password = null)
val encodedEmpty =
OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), paramsEmpty)
val expectedEmpty = buildJsonObject {}
assertEquals(expectedEmpty, encodedEmpty)
assertTrue(encodedEmpty is JsonObject)
assertTrue((encodedEmpty as JsonObject).isEmpty())
}
@Test
fun vaultLockParamsEncodesEmptyJsonObjectWhenNameNull() {
val params = VaultLockParams(name = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun vaultLockParamsEncodesNameWhenSet() {
val params = VaultLockParams(name = "primary-vault")
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params)
val expected =
buildJsonObject {
put("name", "primary-vault")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("name"), (encoded as JsonObject).keys)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("subscription.state", RpcMethods.SUBSCRIPTION_STATE.name)
assertEquals("usage.bars", RpcMethods.USAGE_BARS.name)
assertEquals("subscription.resume", RpcMethods.SUBSCRIPTION_RESUME.name)
assertEquals("vault.list", RpcMethods.VAULT_LIST.name)
assertEquals("vault.sources", RpcMethods.VAULT_SOURCES.name)
assertEquals("subscription.preview", RpcMethods.SUBSCRIPTION_PREVIEW.name)
assertEquals("subscription.change", RpcMethods.SUBSCRIPTION_CHANGE.name)
assertEquals("subscription.upgrade", RpcMethods.SUBSCRIPTION_UPGRADE.name)
assertEquals("vault.source.set", RpcMethods.VAULT_SOURCE_SET.name)
assertEquals("vault.unlock", RpcMethods.VAULT_UNLOCK.name)
assertEquals("vault.lock", RpcMethods.VAULT_LOCK.name)
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_STATE))
assertTrue(RpcMethods.all.contains(RpcMethods.USAGE_BARS))
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_RESUME))
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LIST))
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCES))
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_PREVIEW))
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_CHANGE))
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_UPGRADE))
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCE_SET))
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_UNLOCK))
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LOCK))
}
@Test
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
val arbitraryPayload =
buildJsonObject {
put("tier", "pro")
put("credits", 4200)
put("details", buildJsonObject { put("active", true) })
}
val methods =
listOf(
RpcMethods.SUBSCRIPTION_STATE,
RpcMethods.USAGE_BARS,
RpcMethods.SUBSCRIPTION_RESUME,
RpcMethods.VAULT_LIST,
RpcMethods.VAULT_SOURCES,
RpcMethods.SUBSCRIPTION_PREVIEW,
RpcMethods.SUBSCRIPTION_CHANGE,
RpcMethods.SUBSCRIPTION_UPGRADE,
RpcMethods.VAULT_SOURCE_SET,
RpcMethods.VAULT_UNLOCK,
RpcMethods.VAULT_LOCK,
)
for (method in methods) {
val decoded: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
method.result,
arbitraryPayload,
)
assertEquals("Method ${method.name} must pass raw result untouched", arbitraryPayload, decoded)
}
}
}
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatCommandsTypesTest {
private val json = OkHttpProvider.json
@Test
fun commandsCatalogEncodesAsEmptyObject() {
assertEquals(
buildJsonObject { },
json.encodeToJsonElement(CommandsCatalogParams.serializer(), CommandsCatalogParams),
)
}
@Test
fun commandDispatchKeepsEmptyArgAndOmitsNullOnes() {
assertEquals(
buildJsonObject {
put("name", "help")
put("arg", "")
put("session_id", "s1")
},
json.encodeToJsonElement(
CommandDispatchParams.serializer(),
CommandDispatchParams(name = "help", arg = "", sessionId = "s1"),
),
)
assertEquals(
buildJsonObject { put("name", "help") },
json.encodeToJsonElement(CommandDispatchParams.serializer(), CommandDispatchParams(name = "help")),
)
}
@Test
fun slashExecEncodesSessionAndCommand() {
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("command", "/status")
},
json.encodeToJsonElement(
SlashExecParams.serializer(),
SlashExecParams(sessionId = "s1", command = "/status"),
),
)
}
@Test
fun fileAttachSendsDataUrlAndNameWithoutPath() {
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("data_url", "data:text/plain;base64,QQ==")
put("name", "a.txt")
},
json.encodeToJsonElement(
FileAttachParams.serializer(),
FileAttachParams(sessionId = "s1", dataUrl = "data:text/plain;base64,QQ==", name = "a.txt"),
),
)
}
@Test
fun imageAttachBytesEncodesContentBase64FilenameAndExt() {
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("content_base64", "data:image/png;base64,QQ==")
put("filename", "p.png")
put("ext", "png")
},
json.encodeToJsonElement(
ImageAttachBytesParams.serializer(),
ImageAttachBytesParams(
sessionId = "s1",
contentBase64 = "data:image/png;base64,QQ==",
filename = "p.png",
ext = "png",
),
),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
listOf(
RpcMethods.COMMANDS_CATALOG to "commands.catalog",
RpcMethods.COMMAND_DISPATCH to "command.dispatch",
RpcMethods.SLASH_EXEC to "slash.exec",
RpcMethods.FILE_ATTACH to "file.attach",
RpcMethods.IMAGE_ATTACH_BYTES to "image.attach_bytes",
).forEach { (method, name) ->
assertTrue("$name not in RpcMethods.all", method in RpcMethods.all)
assertEquals(name, method.name)
}
}
}
@@ -0,0 +1,102 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConfigModelTypesTest {
private val json = OkHttpProvider.json
@Test
fun configSetEncodesOnlyKeyAndValueByDefault() {
assertEquals(
buildJsonObject {
put("key", "fast")
put("value", "normal")
},
json.encodeToJsonElement(ConfigSetParams.serializer(), ConfigSetParams(key = "fast", value = "normal")),
)
}
@Test
fun configSetEncodesSessionScopeAndConfirmWhenPresent() {
assertEquals(
buildJsonObject {
put("key", "model")
put("value", "gpt-4o --provider openai --session")
put("session_id", "s1")
put("scope", "global")
put("confirm_expensive_model", true)
},
json.encodeToJsonElement(
ConfigSetParams.serializer(),
ConfigSetParams(
key = "model",
value = "gpt-4o --provider openai --session",
sessionId = "s1",
scope = "global",
confirmExpensiveModel = true,
),
),
)
}
@Test
fun configGetOmitsNullSessionAndCwd() {
assertEquals(
buildJsonObject {
put("key", "reasoning")
put("session_id", "s1")
},
json.encodeToJsonElement(
ConfigGetParams.serializer(),
ConfigGetParams(key = "reasoning", sessionId = "s1"),
),
)
}
@Test
fun modelOptionsKeepsExplicitFalseAndOmitsUnsetFlags() {
assertEquals(
buildJsonObject {
put("refresh", true)
put("include_unconfigured", false)
},
json.encodeToJsonElement(
ModelOptionsParams.serializer(),
ModelOptionsParams(refresh = true, includeUnconfigured = false),
),
)
assertEquals(
buildJsonObject { },
json.encodeToJsonElement(ModelOptionsParams.serializer(), ModelOptionsParams()),
)
}
@Test
fun clientCapabilitiesAlwaysEncodesServerRequests() {
assertEquals(
buildJsonObject { put("server_requests", true) },
json.encodeToJsonElement(
ClientCapabilitiesParams.serializer(),
ClientCapabilitiesParams(serverRequests = true),
),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
listOf(
RpcMethods.CONFIG_SET to "config.set",
RpcMethods.CONFIG_GET to "config.get",
RpcMethods.MODEL_OPTIONS to "model.options",
RpcMethods.CLIENT_CAPABILITIES to "client.capabilities",
).forEach { (method, name) ->
assertTrue("$name not in RpcMethods.all", method in RpcMethods.all)
assertEquals(name, method.name)
}
}
}
@@ -0,0 +1,257 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionOperationTypesTest {
@Test
fun connectionRespondParamsWithApprovedEnvAndAccountOwnerEncodesExactWireJson() {
val params =
ConnectionRespondParams(
owner = ConnectorOwner.account(),
opId = "op-approved-1",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "linear",
status = "approved",
detail = "User accepted linear integration",
env = mapOf("API_KEY" to "dummy_secret_key_123"),
),
),
settledBy = null,
),
profile = "work",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-approved-1")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "linear")
put("status", "approved")
put("detail", "User accepted linear integration")
put(
"env",
buildJsonObject {
put("API_KEY", "dummy_secret_key_123")
},
)
},
)
},
)
},
)
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "result", "profile"), (encoded as JsonObject).keys)
val resultObj = (encoded as JsonObject)["result"] as JsonObject
assertEquals(setOf("targets"), resultObj.keys)
assertFalse(resultObj.containsKey("settled_by"))
}
@Test
fun connectionRespondParamsWithSkippedOmittedEnvAndSessionOwnerEncodesExactWireJson() {
val params =
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-xyz"),
opId = "op-skip-2",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "skipped",
detail = null,
env = null,
),
),
settledBy = null,
),
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-xyz")
},
)
put("op_id", "op-skip-2")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "skipped")
},
)
},
)
},
)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
val resultObj = (encoded as JsonObject)["result"] as JsonObject
assertEquals(setOf("targets"), resultObj.keys)
assertFalse(resultObj.containsKey("settled_by"))
val targetObj = (resultObj["targets"] as kotlinx.serialization.json.JsonArray)[0] as JsonObject
assertEquals(setOf("name", "status"), targetObj.keys)
assertFalse(targetObj.containsKey("env"))
assertFalse(targetObj.containsKey("detail"))
}
@Test
fun connectionRespondParamsWithContinueAndOmittedTargetsEncodesExactWireJson() {
val params =
ConnectionRespondParams(
owner = ConnectorOwner.session("sess-cont-3"),
opId = "op-cont-3",
result =
ConnectionAnswer(
targets = null,
settledBy = "continue",
),
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-cont-3")
},
)
put("op_id", "op-cont-3")
put(
"result",
buildJsonObject {
put("settled_by", "continue")
},
)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
val resultObj = (encoded as JsonObject)["result"] as JsonObject
assertEquals(setOf("settled_by"), resultObj.keys)
assertFalse(resultObj.containsKey("targets"))
}
@Test
fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndEncodesAllFieldsWhenProfilePresent() {
val params =
ConnectorsOperationStatusParams(
owner = ConnectorOwner.session("sess-wake-1"),
opId = "op-wake-1",
profile = "staging",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-wake-1")
},
)
put("op_id", "op-wake-1")
put("profile", "staging")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndOmitsProfileWhenNull() {
val params =
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-wake-2",
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-wake-2")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
}
@Test
fun connectionRespondAndWakeAreRegisteredInGatewayContractRpcMethodsAll() {
val allMethods = RpcMethods.all
val respondMethod = allMethods.firstOrNull { it.name == "connection.respond" }
val wakeMethod = allMethods.firstOrNull { it.name == "connectors.operation.wake" }
org.junit.Assert.assertNotNull("connection.respond must be registered in RpcMethods.all", respondMethod)
org.junit.Assert.assertNotNull("connectors.operation.wake must be registered in RpcMethods.all", wakeMethod)
assertEquals(RpcMethods.CONNECTION_RESPOND, respondMethod)
assertEquals(RpcMethods.CONNECTORS_OPERATION_WAKE, wakeMethod)
}
}
@@ -0,0 +1,539 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectorsTypesTest {
@Test
fun connectorOwnerAccountEncodesToTypeAccountWithoutSessionId() {
val owner = ConnectorOwner.account()
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner)
val expected =
buildJsonObject {
put("type", "account")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("type"), (encoded as JsonObject).keys)
}
@Test
fun connectorOwnerSessionEncodesToTypeSessionAndSessionId() {
val owner = ConnectorOwner.session("s1")
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner)
val expected =
buildJsonObject {
put("type", "session")
put("session_id", "s1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("type", "session_id"), (encoded as JsonObject).keys)
}
@Test
fun connectorsListParamsEncodesOwnerAndProfileWhenProfileProvided() {
val params = ConnectorsListParams(owner = ConnectorOwner.account(), profile = "work")
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsListParamsOmitsProfileWhenNull() {
val params = ConnectorsListParams(owner = ConnectorOwner.session("s1"), profile = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "s1")
},
)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner"), (encoded as JsonObject).keys)
}
@Test
fun connectorsConnectParamsEncodesAllFieldsWhenPresent() {
val params =
ConnectorsConnectParams(
owner = ConnectorOwner.account(),
connectors = listOf("slack", "github"),
reconnect = true,
profile = "personal",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put(
"connectors",
buildJsonArray {
add(JsonPrimitive("slack"))
add(JsonPrimitive("github"))
},
)
put("reconnect", true)
put("profile", "personal")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "connectors", "reconnect", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsConnectParamsEncodesExplicitFalseForReconnect() {
val params =
ConnectorsConnectParams(
owner = ConnectorOwner.session("s2"),
connectors = listOf("jira"),
reconnect = false,
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "s2")
},
)
put(
"connectors",
buildJsonArray {
add(JsonPrimitive("jira"))
},
)
put("reconnect", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "connectors", "reconnect"), (encoded as JsonObject).keys)
assertFalse((encoded as JsonObject).containsKey("profile"))
}
@Test
fun connectorsConnectParamsOmitsNullReconnectAndProfile() {
val params =
ConnectorsConnectParams(
owner = ConnectorOwner.account(),
connectors = emptyList(),
reconnect = null,
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("connectors", buildJsonArray {})
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "connectors"), (encoded as JsonObject).keys)
}
@Test
fun connectorsOperationStatusParamsEncodesAllFieldsWhenPresent() {
val params =
ConnectorsOperationStatusParams(
owner = ConnectorOwner.session("sess-1"),
opId = "op-99",
profile = "work",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "sess-1")
},
)
put("op_id", "op-99")
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsOperationStatusParamsOmitsNullProfile() {
val params =
ConnectorsOperationStatusParams(
owner = ConnectorOwner.account(),
opId = "op-100",
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
val expected =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "account")
},
)
put("op_id", "op-100")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys)
}
@Test
fun connectorsCatalogParamsEncodesProfileWhenPresent() {
val params = ConnectorsCatalogParams(profile = "default")
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params)
val expected =
buildJsonObject {
put("profile", "default")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsCatalogParamsOmitsNullProfileResultingInEmptyJsonObject() {
val params = ConnectorsCatalogParams(profile = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun connectorsAccountsParamsEncodesAllFieldsWhenPresent() {
val params = ConnectorsAccountsParams(connector = "google", profile = "work")
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params)
val expected =
buildJsonObject {
put("connector", "google")
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("connector", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsAccountsParamsOmitsNullFieldsResultingInEmptyJsonObject() {
val params = ConnectorsAccountsParams(connector = null, profile = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun connectorsAccountsParamsEncodesConnectorOnlyWhenProfileNull() {
val params = ConnectorsAccountsParams(connector = "github", profile = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params)
val expected =
buildJsonObject {
put("connector", "github")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("connector"), (encoded as JsonObject).keys)
}
@Test
fun connectorsAccountsRemoveParamsEncodesConnectionIdAndProfile() {
val params = ConnectorsAccountsRemoveParams(connectionId = "conn-123", profile = "work")
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params)
val expected =
buildJsonObject {
put("connection_id", "conn-123")
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("connection_id", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsAccountsRemoveParamsOmitsNullProfile() {
val params = ConnectorsAccountsRemoveParams(connectionId = "conn-456", profile = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params)
val expected =
buildJsonObject {
put("connection_id", "conn-456")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("connection_id"), (encoded as JsonObject).keys)
}
@Test
fun connectorsPolicyGetParamsEncodesProfileWhenPresent() {
val params = ConnectorsPolicyGetParams(profile = "custom-profile")
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params)
val expected =
buildJsonObject {
put("profile", "custom-profile")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsPolicyGetParamsOmitsNullProfileResultingInEmptyJsonObject() {
val params = ConnectorsPolicyGetParams(profile = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun connectorsPolicySetParamsEncodesNestedChangeWithArraysUntouched() {
val nestedChange =
buildJsonObject {
put("action", "allow")
put(
"tools",
buildJsonArray {
add(JsonPrimitive("gmail.send"))
add(JsonPrimitive("gmail.list"))
},
)
put(
"scope",
buildJsonObject {
put("level", "read-write")
put(
"allowed_users",
buildJsonArray {
add(JsonPrimitive("alice"))
add(JsonPrimitive("bob"))
},
)
},
)
}
val params =
ConnectorsPolicySetParams(
change = nestedChange,
expectedRevision = "4",
profile = "work",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params)
val expected =
buildJsonObject {
put("change", nestedChange)
put("expected_revision", "4")
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("change", "expected_revision", "profile"), (encoded as JsonObject).keys)
assertEquals(nestedChange, (encoded as JsonObject)["change"])
}
@Test
fun connectorsPolicySetParamsOmitsNullProfile() {
val change = buildJsonObject { put("enabled", true) }
val params =
ConnectorsPolicySetParams(
change = change,
expectedRevision = "1",
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params)
val expected =
buildJsonObject {
put("change", change)
put("expected_revision", "1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("change", "expected_revision"), (encoded as JsonObject).keys)
}
@Test
fun connectorsToolsParamsEncodesAllFieldsWhenPresent() {
val params =
ConnectorsToolsParams(
slug = "gmail",
refresh = true,
profile = "work",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params)
val expected =
buildJsonObject {
put("slug", "gmail")
put("refresh", true)
put("profile", "work")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("slug", "refresh", "profile"), (encoded as JsonObject).keys)
}
@Test
fun connectorsToolsParamsEncodesExplicitFalseForRefresh() {
val params =
ConnectorsToolsParams(
slug = "slack",
refresh = false,
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params)
val expected =
buildJsonObject {
put("slug", "slack")
put("refresh", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("slug", "refresh"), (encoded as JsonObject).keys)
}
@Test
fun connectorsToolsParamsOmitsNullRefreshAndProfile() {
val params =
ConnectorsToolsParams(
slug = "github",
refresh = null,
profile = null,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params)
val expected =
buildJsonObject {
put("slug", "github")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("slug"), (encoded as JsonObject).keys)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("connectors.list", RpcMethods.CONNECTORS_LIST.name)
assertEquals("connectors.connect", RpcMethods.CONNECTORS_CONNECT.name)
assertEquals("connectors.operation.status", RpcMethods.CONNECTORS_OPERATION_STATUS.name)
assertEquals("connectors.catalog", RpcMethods.CONNECTORS_CATALOG.name)
assertEquals("connectors.accounts", RpcMethods.CONNECTORS_ACCOUNTS.name)
assertEquals("connectors.accounts.remove", RpcMethods.CONNECTORS_ACCOUNTS_REMOVE.name)
assertEquals("connectors.policy.get", RpcMethods.CONNECTORS_POLICY_GET.name)
assertEquals("connectors.policy.set", RpcMethods.CONNECTORS_POLICY_SET.name)
assertEquals("connectors.tools", RpcMethods.CONNECTORS_TOOLS.name)
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_LIST))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CONNECT))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_OPERATION_STATUS))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CATALOG))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS_REMOVE))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_GET))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_SET))
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_TOOLS))
}
@Test
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
val arbitraryPayload =
buildJsonObject {
put("status", "ok")
put(
"items",
buildJsonArray {
add(JsonPrimitive("elem1"))
add(JsonPrimitive("elem2"))
},
)
put("nested", buildJsonObject { put("revision", 10) })
}
val methods =
listOf(
RpcMethods.CONNECTORS_LIST,
RpcMethods.CONNECTORS_CONNECT,
RpcMethods.CONNECTORS_OPERATION_STATUS,
RpcMethods.CONNECTORS_CATALOG,
RpcMethods.CONNECTORS_ACCOUNTS,
RpcMethods.CONNECTORS_ACCOUNTS_REMOVE,
RpcMethods.CONNECTORS_POLICY_GET,
RpcMethods.CONNECTORS_POLICY_SET,
RpcMethods.CONNECTORS_TOOLS,
)
for (method in methods) {
val decoded: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
method.result,
arbitraryPayload,
)
assertEquals("Method " + method.name + " must pass raw result untouched", arbitraryPayload, decoded)
}
}
}
@@ -0,0 +1,107 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.ws.GatewayContract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ParamsDescriptorCheckTest {
@Serializable
private data class ValidParams(
@SerialName("session_id") val sessionId: String,
@SerialName("last_seen") val lastSeen: Int? = null,
)
@Serializable
private data class ExtraFieldParams(
@SerialName("session_id") val sessionId: String,
@SerialName("since_seq") val sinceSeq: Int? = null,
)
@Serializable
private data class MissingRequiredParams(
@SerialName("last_seen") val lastSeen: Int? = null,
)
@Serializable
private data class DefaultOnRequiredParams(
@SerialName("session_id") val sessionId: String = "default_id",
)
@Serializable
private data class NullableRequiredParams(
@SerialName("session_id") val sessionId: String?,
)
@Serializable
private data class DeliverMissingProfileParams(
val message: String,
)
@Test
fun correctClassPasses() {
val problems =
GatewayContract.paramsDescriptorProblems(
"session.events.since",
ValidParams.serializer().descriptor,
)
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
@Test
fun extraFieldIsRejected() {
val problems =
GatewayContract.paramsDescriptorProblems(
"session.events.since",
ExtraFieldParams.serializer().descriptor,
)
assertEquals(1, problems.count { "unknown param 'since_seq'" in it })
}
@Test
fun missingRequiredFieldIsRejected() {
val problems =
GatewayContract.paramsDescriptorProblems(
"session.events.since",
MissingRequiredParams.serializer().descriptor,
)
assertEquals(1, problems.count { "missing required param 'session_id'" in it })
}
@Test
fun requiredFieldWithDefaultValueIsRejected() {
val problems =
GatewayContract.paramsDescriptorProblems(
"session.events.since",
DefaultOnRequiredParams.serializer().descriptor,
)
assertEquals(1, problems.count { "required param 'session_id' must not be optional" in it })
}
@Test
fun requiredFieldTypedNullableIsRejected() {
val problems =
GatewayContract.paramsDescriptorProblems(
"session.events.since",
NullableRequiredParams.serializer().descriptor,
)
assertEquals(
1,
problems.count { "required param 'session_id' must not be nullable unless schema allows null" in it },
)
}
@Test
fun omittingRequiredProfileIsRejectedLikeAnyRequiredParam() {
// bot_relay.deliver has required: ["profile", "message"]. Profile injection is skipped when no
// profile is active, so `profile` gets no special treatment: the params class must declare it.
val problems =
GatewayContract.paramsDescriptorProblems(
"bot_relay.deliver",
DeliverMissingProfileParams.serializer().descriptor,
)
assertEquals(1, problems.count { "missing required param 'profile'" in it })
}
}
@@ -0,0 +1,60 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PluginsProjectsTypesTest {
private fun encode(params: PluginsManageParams) =
OkHttpProvider.json.encodeToJsonElement(PluginsManageParams.serializer(), params)
@Test
fun listEncodesOnlyAction() {
assertEquals(buildJsonObject { put("action", "list") }, encode(PluginsManageParams(action = "list")))
}
@Test
fun removeEncodesActionAndName() {
assertEquals(
buildJsonObject {
put("action", "remove")
put("name", "demo")
},
encode(PluginsManageParams(action = "remove", name = "demo")),
)
}
@Test
fun settingsEncodesKeyValuesAndExplicitProfile() {
val values = buildJsonObject { put("port", JsonPrimitive(8080)) }
assertEquals(
buildJsonObject {
put("action", "settings")
put("profile", "staging")
put("key", "camofox")
put("values", values)
},
encode(PluginsManageParams(action = "settings", key = "camofox", values = values, profile = "staging")),
)
}
@Test
fun projectsListEncodesAsEmptyObject() {
assertEquals(
buildJsonObject { },
OkHttpProvider.json.encodeToJsonElement(ProjectsListParams.serializer(), ProjectsListParams),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
assertTrue(RpcMethods.PLUGINS_MANAGE in RpcMethods.all)
assertTrue(RpcMethods.PROJECTS_LIST in RpcMethods.all)
assertEquals("plugins.manage", RpcMethods.PLUGINS_MANAGE.name)
assertEquals("projects.list", RpcMethods.PROJECTS_LIST.name)
}
}
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ProcessTypesTest {
@Test
fun killEncodesExactlySessionAndProcessIds() {
val encoded =
OkHttpProvider.json.encodeToJsonElement(
ProcessKillParams.serializer(),
ProcessKillParams(sessionId = "s1", processId = "p1"),
)
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("process_id", "p1")
},
encoded,
)
}
@Test
fun stopEncodesAnEmptyObjectAndDecodesKilledCount() {
val encoded = OkHttpProvider.json.encodeToJsonElement(ProcessStopParams.serializer(), ProcessStopParams)
assertEquals(buildJsonObject {}, encoded)
val result = OkHttpProvider.json.decodeFromString(ProcessStopResult.serializer(), """{"killed":3}""")
assertEquals(ProcessStopResult(killed = 3), result)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
assertTrue(RpcMethods.PROCESS_STOP in RpcMethods.all)
assertEquals("process.stop", RpcMethods.PROCESS_STOP.name)
assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all)
assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all)
assertEquals("process.list", RpcMethods.PROCESS_LIST.name)
assertEquals("process.kill", RpcMethods.PROCESS_KILL.name)
}
}
@@ -0,0 +1,54 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfilesTypesTest {
private fun encode(params: ProfilesConfigureParams) =
OkHttpProvider.json.encodeToJsonElement(ProfilesConfigureParams.serializer(), params)
@Test
fun configureEncodesNameAndUiMetaOnly() {
val meta = buildJsonObject { put("hermes-bots-groups", buildJsonObject { put("version", 3) }) }
assertEquals(
buildJsonObject {
put("name", "default")
put("ui_meta", meta)
},
encode(ProfilesConfigureParams(name = "default", uiMeta = meta)),
)
}
@Test
fun configureIncludesSoulWhenPresent() {
val meta = buildJsonObject { put("hermes-bots", buildJsonObject { put("title", "Scout") }) }
assertEquals(
buildJsonObject {
put("name", "scout")
put("ui_meta", meta)
put("soul", "# Scout")
},
encode(ProfilesConfigureParams(name = "scout", uiMeta = meta, soul = "# Scout")),
)
}
@Test
fun listEncodesAsEmptyObject() {
assertEquals(
buildJsonObject { },
OkHttpProvider.json.encodeToJsonElement(ProfilesListParams.serializer(), ProfilesListParams),
)
}
@Test
fun descriptorsAreRegisteredWithContractNames() {
assertTrue(RpcMethods.PROFILES_LIST in RpcMethods.all)
assertTrue(RpcMethods.PROFILES_CONFIGURE in RpcMethods.all)
assertEquals("profiles.list", RpcMethods.PROFILES_LIST.name)
assertEquals("profiles.configure", RpcMethods.PROFILES_CONFIGURE.name)
}
}
@@ -0,0 +1,119 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionControlTypesTest {
@Test
fun sessionInterruptParamsEncodesSessionIdExactly() {
val params = SessionInterruptParams(sessionId = "sess-int-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionInterruptParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-int-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun sessionCorrectionParamsEncodesSessionIdAndTextExactly() {
val params = SessionCorrectionParams(sessionId = "sess-corr-1", text = "stop that action")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCorrectionParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-corr-1")
put("text", "stop that action")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
}
@Test
fun sessionInterruptResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("status", "interrupted")
put("unknown_key", "ignored_value")
put("extra_flag", true)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionInterruptResult.serializer(),
payload,
)
assertEquals("interrupted", decoded.status)
}
@Test
fun sessionInterruptResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionInterruptResult.serializer(),
payload,
)
assertNull(decoded.status)
}
@Test
fun sessionCorrectionResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("status", "applied")
put("text", "stop that action")
put("ignored_num", 42)
put("nested", buildJsonObject { put("ignored", "yes") })
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionCorrectionResult.serializer(),
payload,
)
assertEquals("applied", decoded.status)
assertEquals("stop that action", decoded.text)
}
@Test
fun sessionCorrectionResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionCorrectionResult.serializer(),
payload,
)
assertNull(decoded.status)
assertNull(decoded.text)
}
@Test
fun rpcMethodsRegistrationMatchesControlContract() {
assertEquals("session.interrupt", RpcMethods.SESSION_INTERRUPT.name)
assertEquals("session.steer", RpcMethods.SESSION_STEER.name)
assertEquals("session.redirect", RpcMethods.SESSION_REDIRECT.name)
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_INTERRUPT))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_STEER))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_REDIRECT))
}
}
@@ -0,0 +1,126 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
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 kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionEventsSinceTypesTest {
@Test
fun paramsWithLastSeenEncodesToExpectedJsonObject() {
val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = 7)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s1")
put("last_seen", 7)
}
assertEquals(expected, encoded)
}
@Test
fun paramsWithNullLastSeenEncodesToSessionIdOnly() {
val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = null)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun resultDecodesFullRealisticPayloadIgnoringUnknownKeys() {
val payload =
buildJsonObject {
put(
"events",
kotlinx.serialization.json.buildJsonArray {
add(
buildJsonObject {
put("type", "message.token")
put("seq", 1)
put("session_id", "s1")
},
)
},
)
put("latest_seq", 42)
put("truncated", false)
put("epoch", "ep-999")
// Extra unknown keys from server that must be ignored
put("count", 1)
put(
"open_requests",
kotlinx.serialization.json.buildJsonArray {
add(JsonPrimitive("req-1"))
},
)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionEventsSinceResult.serializer(),
payload,
)
assertEquals("ep-999", decoded.epoch)
assertEquals(42, decoded.latestSeq)
assertEquals(false, decoded.truncated)
assertEquals(1, decoded.events?.size)
}
@Test
fun resultDecodesEmptyObjectToAllNullDefaults() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionEventsSinceResult.serializer(),
payload,
)
assertNull(decoded.events)
assertNull(decoded.latestSeq)
assertNull(decoded.truncated)
assertNull(decoded.epoch)
}
@Test
fun resultToleratesTruncatedAbsent() {
val payload =
buildJsonObject {
put("latest_seq", 10)
put("epoch", "epoch-1")
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionEventsSinceResult.serializer(),
payload,
)
assertNull(decoded.truncated)
assertEquals(10, decoded.latestSeq)
assertEquals("epoch-1", decoded.epoch)
assertNull(decoded.events)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("session.events.since", RpcMethods.SESSION_EVENTS_SINCE.name)
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_EVENTS_SINCE))
}
}
@@ -0,0 +1,295 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionMiscTypesTest {
@Test
fun sessionListParamsEncodesToEmptyJsonObject() {
val params = SessionListParams
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionListParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun sessionIdParamsEncodesSessionIdExactly() {
val params = SessionIdParams(sessionId = "sess-misc-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-misc-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun sessionBranchParamsEncodesSessionIdOnlyWhenNameNull() {
val params = SessionBranchParams(sessionId = "sess-branch-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-branch-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun sessionBranchParamsEncodesSessionIdAndNameWhenPresent() {
val params = SessionBranchParams(sessionId = "sess-branch-2", name = "experiment-fork")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-branch-2")
put("name", "experiment-fork")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
}
@Test
fun sessionBranchParamsPassesBlankNameThroughAsIs() {
val params = SessionBranchParams(sessionId = "sess-branch-3", name = " ")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-branch-3")
put("name", " ")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
}
@Test
fun sessionBranchWholeParamsEncodesSessionIdOnlyWhenNameNull() {
val params = SessionBranchWholeParams(sessionId = "sess-whole-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-whole-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun sessionBranchWholeParamsEncodesSessionIdAndNameWhenPresent() {
val params = SessionBranchWholeParams(sessionId = "sess-whole-2", name = "full-tree-fork")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-whole-2")
put("name", "full-tree-fork")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
}
@Test
fun sessionBranchWholeParamsPassesBlankNameThroughAsIs() {
val params = SessionBranchWholeParams(sessionId = "sess-whole-3", name = " \t ")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-whole-3")
put("name", " \t ")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
}
@Test
fun sessionCompressParamsEncodesSessionIdOnlyWhenFocusTopicNull() {
val params = SessionCompressParams(sessionId = "sess-comp-1")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-comp-1")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
}
@Test
fun sessionCompressParamsEncodesSessionIdAndFocusTopicWhenPresent() {
val params = SessionCompressParams(sessionId = "sess-comp-2", focusTopic = "summarize-db-schema")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-comp-2")
put("focus_topic", "summarize-db-schema")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys)
}
@Test
fun sessionCompressParamsPassesBlankFocusTopicThroughAsIs() {
val params = SessionCompressParams(sessionId = "sess-comp-3", focusTopic = " ")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-comp-3")
put("focus_topic", " ")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys)
}
@Test
fun promptBtwParamsEncodesSessionIdAndText() {
val params = PromptBtwParams(sessionId = "sess-btw-1", text = "by the way, take note")
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptBtwParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "sess-btw-1")
put("text", "by the way, take note")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
}
@Test
fun promptBtwResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("task_id", "task-xyz-987")
put("unknown_flag", true)
put("extra_num", 100)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
PromptBtwResult.serializer(),
payload,
)
assertEquals("task-xyz-987", decoded.taskId)
}
@Test
fun promptBtwResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
PromptBtwResult.serializer(),
payload,
)
assertNull(decoded.taskId)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("session.list", RpcMethods.SESSION_LIST.name)
assertEquals("session.branch", RpcMethods.SESSION_BRANCH.name)
assertEquals("session.branch_whole", RpcMethods.SESSION_BRANCH_WHOLE.name)
assertEquals("session.compress", RpcMethods.SESSION_COMPRESS.name)
assertEquals("session.context_breakdown", RpcMethods.SESSION_CONTEXT_BREAKDOWN.name)
assertEquals("session.usage", RpcMethods.SESSION_USAGE.name)
assertEquals("prompt.btw", RpcMethods.PROMPT_BTW.name)
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_LIST))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH_WHOLE))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_COMPRESS))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CONTEXT_BREAKDOWN))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_USAGE))
assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_BTW))
}
@Test
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
val arbitraryPayload =
buildJsonObject {
put("status", "ok")
put("tokens", 4567)
put("nested", buildJsonObject { put("item", "val") })
}
val decodedList: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_LIST.result,
arbitraryPayload,
)
val decodedBranch: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_BRANCH.result,
arbitraryPayload,
)
val decodedBranchWhole: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_BRANCH_WHOLE.result,
arbitraryPayload,
)
val decodedCompress: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_COMPRESS.result,
arbitraryPayload,
)
val decodedBreakdown: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_CONTEXT_BREAKDOWN.result,
arbitraryPayload,
)
val decodedUsage: JsonElement =
OkHttpProvider.json.decodeFromJsonElement(
RpcMethods.SESSION_USAGE.result,
arbitraryPayload,
)
assertEquals(arbitraryPayload, decodedList)
assertEquals(arbitraryPayload, decodedBranch)
assertEquals(arbitraryPayload, decodedBranchWhole)
assertEquals(arbitraryPayload, decodedCompress)
assertEquals(arbitraryPayload, decodedBreakdown)
assertEquals(arbitraryPayload, decodedUsage)
}
}
@@ -0,0 +1,290 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
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 kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionPromptTypesTest {
@Test
fun createWithSourceOnlyEncodesExactlySource() {
val params = SessionCreateParams(source = "cli")
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected =
buildJsonObject {
put("source", "cli")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("source"), (encoded as JsonObject).keys)
}
@Test
fun groupStyleCreateEncodesExpectedFourKeysWithHiddenBoolean() {
val params =
SessionCreateParams(
profile = "work",
title = "Team sync",
source = "matrix",
hidden = true,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected =
buildJsonObject {
put("source", "matrix")
put("profile", "work")
put("title", "Team sync")
put("hidden", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
val hiddenElement = (encoded as JsonObject)["hidden"] as? JsonPrimitive
assertTrue(hiddenElement != null && hiddenElement.isString.not())
}
@Test
fun allNullCreateParamsEncodeToEmptyObject() {
val params = SessionCreateParams()
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected = buildJsonObject {}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertTrue((encoded as JsonObject).isEmpty())
}
@Test
fun createWithExplicitFalseHiddenIsEncoded() {
val params = SessionCreateParams(hidden = false)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
val expected =
buildJsonObject {
put("hidden", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("hidden"), (encoded as JsonObject).keys)
}
@Test
fun resumeEncodesSessionIdAndOmitMessagesExactly() {
val params = SessionResumeParams(sessionId = "s-123", omitMessages = true)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-123")
put("omit_messages", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
}
@Test
fun resumeWithProfileEncodesExpectedThreeKeys() {
val params =
SessionResumeParams(
sessionId = "s-456",
omitMessages = true,
profile = "personal",
)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-456")
put("omit_messages", true)
put("profile", "personal")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
}
@Test
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-789")
put("omit_messages", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
}
@Test
fun submitEncodesSessionIdAndText() {
val params = PromptSubmitParams(sessionId = "s-100", text = "hello hermes")
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-100")
put("text", "hello hermes")
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
}
@Test
fun submitWithQueuedTrueEncodesQueued() {
val params = PromptSubmitParams(sessionId = "s-101", text = "queued prompt", queued = true)
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-101")
put("text", "queued prompt")
put("queued", true)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
}
@Test
fun submitWithExplicitFalseQueuedIsEncoded() {
val params = PromptSubmitParams(sessionId = "s-102", text = "direct prompt", queued = false)
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
val expected =
buildJsonObject {
put("session_id", "s-102")
put("text", "direct prompt")
put("queued", false)
}
assertEquals(expected, encoded)
assertTrue(encoded is JsonObject)
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
}
@Test
fun sessionCreateResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("session_id", "sess-created-1")
put("stored_session_id", "stored-1")
put("created_at", 123456789)
put("unknown_flag", true)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionCreateResult.serializer(),
payload,
)
assertEquals("sess-created-1", decoded.sessionId)
assertEquals("stored-1", decoded.storedSessionId)
}
@Test
fun sessionCreateResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionCreateResult.serializer(),
payload,
)
assertNull(decoded.sessionId)
assertNull(decoded.storedSessionId)
}
@Test
fun sessionResumeResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("session_id", "sess-resumed-1")
put("active_model", "gemini")
put("unread_count", 0)
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionResumeResult.serializer(),
payload,
)
assertEquals("sess-resumed-1", decoded.sessionId)
}
@Test
fun sessionResumeResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
SessionResumeResult.serializer(),
payload,
)
assertNull(decoded.sessionId)
}
@Test
fun promptSubmitResultDecodesFullPayloadAndIgnoresUnknownKeys() {
val payload =
buildJsonObject {
put("status", "accepted")
put("ticket_id", "tick-42")
put("queued_at", "2026-09-29T10:00:00Z")
}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
PromptSubmitResult.serializer(),
payload,
)
assertEquals("accepted", decoded.status)
}
@Test
fun promptSubmitResultDecodesEmptyObjectToNulls() {
val payload = buildJsonObject {}
val decoded =
OkHttpProvider.json.decodeFromJsonElement(
PromptSubmitResult.serializer(),
payload,
)
assertNull(decoded.status)
}
@Test
fun rpcMethodsRegistrationMatchesContract() {
assertEquals("session.create", RpcMethods.SESSION_CREATE.name)
assertEquals("session.resume", RpcMethods.SESSION_RESUME.name)
assertEquals("prompt.submit", RpcMethods.PROMPT_SUBMIT.name)
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CREATE))
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_RESUME))
assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_SUBMIT))
}
}
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.data.ws.contract
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SubagentTypesTest {
@Test
fun listEncodesOnlySessionId() {
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), SessionIdParams("s1"))
assertEquals(buildJsonObject { put("session_id", "s1") }, encoded)
}
@Test
fun tailEncodesExactlyTheTwoRequiredIds() {
val encoded =
OkHttpProvider.json.encodeToJsonElement(
SubagentTailParams.serializer(),
SubagentTailParams("s1", "sub-1"),
)
assertEquals(
buildJsonObject {
put("session_id", "s1")
put("subagent_id", "sub-1")
},
encoded,
)
}
@Test
fun descriptorsAreRegisteredAndPreserveRawResults() {
assertTrue(RpcMethods.SUBAGENT_LIST in RpcMethods.all)
assertTrue(RpcMethods.SUBAGENT_TAIL in RpcMethods.all)
assertEquals("subagent.list", RpcMethods.SUBAGENT_LIST.name)
assertEquals("subagent.tail", RpcMethods.SUBAGENT_TAIL.name)
val raw: JsonElement = buildJsonObject { put("future_field", "retained") }
assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_LIST.result, raw))
assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_TAIL.result, raw))
}
}
@@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns true
every { AuthManager.getToken() } returns null
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns null
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns "some-token"
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns "some-token"
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns false
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -407,4 +411,55 @@ class BackgroundConnectionControllerTest {
unmockkObject(NetworkMonitor)
}
}
@Test
fun testOnKeepConnectedDisabled_whenBackgrounded_releasesLeaseAndStopsService() {
var leaseReleased = false
var serviceStopped = false
val snapshot =
BackgroundConnectionSnapshot(
appInForeground = false,
keepConnectedOptIn = false,
pendingReply = false,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
)
val controller =
BackgroundConnectionController(
snapshotProvider = { snapshot },
releaseLease = { leaseReleased = true },
requestServiceStop = { serviceStopped = true },
)
controller.onKeepConnectedDisabled()
assertTrue(leaseReleased)
assertTrue(serviceStopped)
}
@Test
fun testOnKeepConnectedDisabled_whenReplyPending_keepsServiceAndRefreshesNotification() {
var serviceStopped = false
var refreshed: BackgroundNotificationState? = null
val snapshot =
BackgroundConnectionSnapshot(
appInForeground = false,
keepConnectedOptIn = false,
pendingReply = true,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
)
val controller =
BackgroundConnectionController(
snapshotProvider = { snapshot },
releaseLease = {},
requestServiceStop = { serviceStopped = true },
onNotificationStateChanged = { refreshed = it },
)
controller.onKeepConnectedDisabled()
assertFalse(serviceStopped)
assertEquals(BackgroundNotificationState.WaitingForReplies, refreshed)
}
}
@@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest {
assertTrue(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.Connecting, decision.notificationState)
}
// ── Session-completion notify opt-in (implies the keep-connected lease) ──
@Test
fun testBackground_notifyCompletionsOn_alone_holdsServiceAndLease() {
val state =
BackgroundConnectionSnapshot(
appInForeground = false,
isDeparting = false,
keepConnectedOptIn = false,
pendingReply = false,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
isAutoReconnect = true,
hasActiveNetwork = true,
notifyCompletionsOptIn = true,
)
val decision = BackgroundConnectionPolicy.evaluate(state)
assertTrue(decision.shouldHoldService)
assertTrue(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState)
}
@Test
fun testBackground_notifyCompletionsOff_pendingReplyOnly_holdsServiceWithoutLease() {
val state =
BackgroundConnectionSnapshot(
appInForeground = false,
isDeparting = false,
keepConnectedOptIn = false,
pendingReply = true,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
isAutoReconnect = true,
hasActiveNetwork = true,
notifyCompletionsOptIn = false,
)
val decision = BackgroundConnectionPolicy.evaluate(state)
assertTrue(decision.shouldHoldService)
assertFalse(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState)
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.notification
import android.content.Context
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk
import io.mockk.verify
@@ -164,3 +165,116 @@ class ChatNotificationServiceTest {
assertEquals("Answer\ncontinued", plan.correlationText)
}
}
class ChatNotificationServiceActiveListTest {
@Test
fun `parseActiveSessionLookup decodes the typed JSON result and maps ids plus title`() {
val raw =
"""
{"sessions":[
{"id":"rt1","session_key":"stored-1","title":"Nightly report","status":"idle","last_active":1780000000.0},
{"id":"rt2","session_key":"stored-2","status":"idle"}
]}
""".trimIndent()
val found = parseActiveSessionLookup(raw, "rt1")
assertEquals("stored-1", found?.storedId)
assertEquals("Nightly report", found?.title)
// Row without a title: notification falls back to the app name.
val untitled = parseActiveSessionLookup(raw, "rt2")
assertEquals("stored-2", untitled?.storedId)
assertNull(untitled?.title)
}
@Test
fun `parseActiveSessionLookup accepts the JsonElement a typed RPC call returns`() {
val raw = """{"sessions":[{"id":"rt9","session_key":"stored-9","title":"T"}]}"""
val element =
kotlinx.serialization.json.Json
.parseToJsonElement(raw)
val found = parseActiveSessionLookup(element, "rt9")
assertEquals("stored-9", found?.storedId)
assertEquals("T", found?.title)
}
@Test
fun `parseActiveSessionLookup rejects unknown runtime ids and empty keys`() {
val raw = """{"sessions":[{"id":"rt1","session_key":"","status":"idle"}]}"""
assertNull(parseActiveSessionLookup(raw, "rt1"))
assertNull(parseActiveSessionLookup(raw, "missing"))
assertNull(parseActiveSessionLookup(null, "rt1"))
assertNull(parseActiveSessionLookup(raw, ""))
assertNull(parseActiveSessionLookup("not json", "rt1"))
}
}
class MessageCompleteRouteTest {
@Test
fun `active session routes to the reply path`() {
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "rt1")
assertEquals(MessageCompleteRoute.Reply, route)
}
@Test
fun `toggle off keeps legacy behavior for foreign sessions`() {
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = false, eventSessionId = "other")
assertEquals(MessageCompleteRoute.Reply, route)
}
@Test
fun `toggle on routes foreign sessions to the plain alert`() {
assertTrue(
messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "other") is
MessageCompleteRoute.ForeignSession,
)
}
@Test
fun `toggle on with a cleared holder routes the pinned alert`() {
// After a background reconnect the holder is cleared: the user's own
// reply lands in the plain alert (resolved stored id keeps the tap
// working) instead of being dropped.
assertTrue(
messageCompleteRoute(holderSessionId = null, toggleOn = true, eventSessionId = "rt1") is
MessageCompleteRoute.ForeignSession,
)
}
@Test
fun `toggle off with a cleared holder still uses the reply path`() {
val route = messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1")
assertEquals(MessageCompleteRoute.Reply, route)
}
@Test
fun `legacy reply route retires the service when the wait is over`() {
// Toggle off + holder cleared by a reconnect: the route is Reply, and the
// Reply branch always ends in onReplyCompleted(generation).
assertEquals(
MessageCompleteRoute.Reply,
messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1"),
)
var completedGeneration: Long? = null
val controller =
BackgroundConnectionController(
snapshotProvider = {
BackgroundConnectionSnapshot(
appInForeground = false,
isDeparting = false,
keepConnectedOptIn = false,
pendingReply = true,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
isAutoReconnect = true,
hasActiveNetwork = true,
)
},
requestServiceComplete = { gen -> completedGeneration = gen },
)
controller.onReplyCompleted(7L)
assertEquals(7L, completedGeneration)
}
}
@@ -21,6 +21,7 @@ import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.serialization.json.JsonPrimitive
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -108,7 +109,7 @@ class NotificationReplyReceiverTest {
val params = arg<Map<String, Any>>(1)
val result =
if (method == WsMethods.SESSION_RESUME) {
mapOf("session_id" to "runtime-${params["session_id"]}")
mapOf("session_id" to "runtime-${params.str("session_id")}")
} else {
mapOf("accepted" to true)
}
@@ -147,7 +148,7 @@ class NotificationReplyReceiverTest {
verify {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" },
match { it.str("session_id") == "runtime-session-abc" && it.str("text") == "Hello" },
any(),
)
}
@@ -329,19 +330,19 @@ class NotificationReplyReceiverTest {
verify {
HermesWsClient.request(
WsMethods.SESSION_RESUME,
match { it["session_id"] == "session-abc" && it["omit_messages"] == true },
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
any(),
)
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "runtime-session-abc" },
match { it.str("session_id") == "runtime-session-abc" },
any(),
)
}
verify(inverse = true) {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "stale-runtime" },
match { it.str("session_id") == "stale-runtime" },
any(),
)
}
@@ -412,16 +413,23 @@ class NotificationReplyReceiverTest {
kotlinx.coroutines.delay(10000)
}
receiver.onReceive(mockContext, mockIntent)
// Same timeout path as production, with a short deadline instead of 5s.
val shortTimeoutReceiver =
object : NotificationReplyReceiver() {
override fun goAsyncCompat(): BroadcastReceiver.PendingResult = mockPendingResult
// Wait longer than the 5-second timeout, but less than the 10-second delay
Thread.sleep(6000)
override fun buildReplyNotification(context: Context): Notification = mockNotification
override val persistenceTimeoutMs: Long = 50L
}
shortTimeoutReceiver.onReceive(mockContext, mockIntent)
// Timeout should be caught in catch block and logged
verify { android.util.Log.e("NotificationReply", any<String>(), any()) }
verify(timeout = 3_000) { android.util.Log.e("NotificationReply", any<String>(), any()) }
// Pending result must still finish after timeout
verify { mockPendingResult.finish() }
verify(timeout = 3_000) { mockPendingResult.finish() }
}
// ── Helpers ────────────────────────────────────────────────────────────
@@ -447,3 +455,5 @@ class NotificationReplyReceiverTest {
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
}
}
private fun Map<String, Any>.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String
@@ -0,0 +1,64 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import org.junit.Assert.assertTrue
import org.junit.Test
/** Enabled-state contract for the pinned Material 3 defaults, verified again by the device gallery. */
class ThemeComponentContrastTest {
@Test
fun everyResolvedPresetSupportsEnabledComponentStates() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val c = resolveColorScheme(preset, dark)
// PR #1417: these are component-state combinations, not just on-color pairs.
val graphics =
listOf(
"switch on thumb/track" to (c.onPrimary to c.primary),
"switch on track/surface" to (c.primary to c.surface),
"switch off thumb/track" to (c.outline to c.surfaceContainerHighest),
"switch off border/surface" to (c.outline to c.surface),
"radio selected/surface" to (c.primary to c.surface),
"radio unselected/surface" to (c.onSurfaceVariant to c.surface),
"checkbox selected/surface" to (c.primary to c.surface),
"checkbox checkmark/box" to (c.onPrimary to c.primary),
"checkbox unselected/surface" to (c.onSurfaceVariant to c.surface),
"slider active/inactive" to (c.primary to c.secondaryContainer),
"field normal border/surface" to (c.outline to c.surface),
"field focused border/surface" to (c.primary to c.surface),
"field error border/surface" to (c.error to c.surface),
"segment selected border/container" to (c.outline to c.secondaryContainer),
"segment unselected border/surface" to (c.outline to c.surface),
)
val text =
listOf(
"primary foreground/surface" to (c.primary to c.surface),
"primary foreground/background" to (c.primary to c.background),
"error foreground/surface" to (c.error to c.surface),
"error foreground/background" to (c.error to c.background),
"field normal label/surface" to (c.onSurfaceVariant to c.surface),
"field focused label/surface" to (c.primary to c.surface),
"field error label/surface" to (c.error to c.surface),
"segment selected content/container" to (c.onSecondaryContainer to c.secondaryContainer),
"segment unselected content/surface" to (c.onSurface to c.surface),
)
graphics.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 3f) }
text.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 4.5f) }
}
}
}
private fun checkPair(
preset: ThemePreset,
dark: Boolean,
name: String,
pair: Pair<Color, Color>,
minimum: Float,
) {
val a = pair.first.luminance()
val b = pair.second.luminance()
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
assertTrue("$preset dark=$dark $name is $ratio, expected >= $minimum", ratio >= minimum)
}
}
@@ -1,67 +1,164 @@
package com.m57.hermescontrol.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import com.m57.hermescontrol.theme.presets.AmoledTheme
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
import com.m57.hermescontrol.theme.presets.DefaultTheme
import com.m57.hermescontrol.theme.presets.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.statusBadgeColors
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.reflect.Modifier
/**
* Guards the palette template's color invariants:
* 1. Every shipped error slot pair meets >= 3:1 WCAG contrast.
* 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode.
* 2. ThemeMode combinations are validated at construction time.
* 3. One-mode themes resolve and fall back to the default theme correctly.
* 3. Every Material role is explicitly mapped; fixed roles are mode-independent.
* 4. One-mode themes resolve and fall back to the default theme correctly.
*/
class ThemePaletteTest {
private val themes =
listOf(
ThemePreset.DEFAULT to DefaultTheme,
ThemePreset.MONOCHROME to MonochromeTheme,
ThemePreset.GRUVBOX to GruvboxTheme,
ThemePreset.CATPPUCCIN to CatppuccinTheme,
ThemePreset.AMOLED to AmoledTheme,
)
@Test
fun registryCoversEveryPresetExactlyOnce() {
assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset })
}
@Test
fun errorSlotPairsMeetContrastInEveryShippedMode() {
themes.forEach { (preset, theme) ->
fun textSlotPairsMeetContrastInEveryResolvedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val scheme = theme.schemeFor(dark) ?: return@forEach
assertTrue(
"$preset dark=$dark onError/error contrast must be >= 3:1",
contrast(scheme.onError, scheme.error) >= 3f,
)
assertTrue(
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1",
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f,
)
val c = resolveColorScheme(preset, dark)
val pairs =
listOf(
"onPrimary/primary" to (c.onPrimary to c.primary),
"onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer),
"onSecondary/secondary" to (c.onSecondary to c.secondary),
"onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer),
"onTertiary/tertiary" to (c.onTertiary to c.tertiary),
"onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer),
"onBackground/background" to (c.onBackground to c.background),
"onSurface/surface" to (c.onSurface to c.surface),
"onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant),
"onSurface/surfaceDim" to (c.onSurface to c.surfaceDim),
"onSurface/surfaceBright" to (c.onSurface to c.surfaceBright),
"onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest),
"onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow),
"onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer),
"onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh),
"onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest),
"inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface),
"onError/error" to (c.onError to c.error),
"onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer),
"onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed),
"onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed),
"onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim),
"onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim),
"onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed),
"onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed),
"onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim),
"onSecondaryFixedVariant/secondaryFixedDim" to
(c.onSecondaryFixedVariant to c.secondaryFixedDim),
"onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed),
"onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed),
"onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim),
"onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim),
)
pairs.forEach { (name, colors) ->
val ratio = contrast(colors.first, colors.second)
assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
}
}
}
}
@Test
fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() {
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val colors = resolveStatusColors(preset, dark)
StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type ->
// PR #1417: test the mapping consumed by both reusable badge renderers.
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
val ratio = contrast(foreground, background)
assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
}
}
}
}
@Test
fun statusBadgesUseMatchingStatusFillsAndOnColors() {
val c = dummyColors().status
assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c))
assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c))
assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c))
assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c))
}
@Test
fun fixedRolesDoNotChangeBetweenShippedModes() {
ThemeRegistry.forEach { (preset, theme) ->
val dark = theme.darkScheme
val light = theme.lightScheme
if (dark != null && light != null) {
val darkRoles = colorFields(dark).filterKeys { "Fixed" in it }
val lightRoles = colorFields(light).filterKeys { "Fixed" in it }
assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles)
}
}
}
@Test
fun everyMaterialColorRoleIsMappedFromThePalette() {
// Distinct sentinel values catch swapped roles and accidental Material defaults.
val colors = dummyColors()
val expected = colorFields(colors).toMutableMap()
expected["surfaceTint"] = expected.getValue("primary")
expected.putAll(
colorFields(colors.status).filterKeys {
it in
setOf(
"error",
"onError",
"errorContainer",
"onErrorContainer",
)
},
)
val theme = buildTheme(colors, colors)
listOf(theme.darkScheme, theme.lightScheme).forEach { scheme ->
assertEquals(expected, colorFields(requireNotNull(scheme)))
}
}
private fun colorFields(value: Any): Map<String, Long> =
value.javaClass.declaredFields
.filter {
it.type == java.lang.Long.TYPE &&
!Modifier.isStatic(it.modifiers)
}.associate { field ->
field.isAccessible = true
field.name to field.getLong(value)
}
/**
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
* on the screen background (HermesScaffold containerColor = background),
* so the full-bleed text pairs must hold against [ColorScheme.background]:
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
* (The header deliberately avoids `primary`: Nord's light mode reuses its
* pastel Frost accent as primary, which cannot reach 3:1 on a light
* background.)
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
* NORD, which the [themes] list above predates) is covered.
* Header text uses onSurfaceVariant independently of the theme's accent.
* The registry covers every shipped preset and its fallback mode.
*/
@Test
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
ThemePreset.entries.forEach { preset ->
ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark ->
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
val scheme = resolveColorScheme(preset, darkTheme = dark)
assertTrue(
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
contrast(scheme.onSurface, scheme.background) >= 4.5f,
@@ -84,18 +181,18 @@ class ThemePaletteTest {
@Test
fun everyPresetResolvesItsDeclaredModes() {
themes.forEach { (preset, theme) ->
ThemeRegistry.forEach { (preset, theme) ->
listOf(true, false).forEach { dark ->
if (theme.schemeFor(dark) != null) {
assertTrue(
"$preset dark=$dark scheme must resolve",
resolveColorScheme(preset, darkTheme = dark) != null,
)
assertTrue(
"$preset dark=$dark status must resolve",
resolveStatusColors(preset, darkTheme = dark) != null,
)
}
assertSame(
"$preset dark=$dark must resolve its palette or Default fallback",
theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark),
resolveColorScheme(preset, dark),
)
assertSame(
"$preset dark=$dark must resolve its status or Default fallback",
theme.statusFor(dark) ?: DefaultTheme.statusFor(dark),
resolveStatusColors(preset, dark),
)
}
}
}
@@ -169,50 +266,64 @@ class ThemePaletteTest {
private fun dummyColors(): PaletteColors =
PaletteColors(
primary = Color.White,
onPrimary = Color.Black,
primaryContainer = Color.White,
onPrimaryContainer = Color.Black,
secondary = Color.White,
onSecondary = Color.Black,
secondaryContainer = Color.White,
onSecondaryContainer = Color.Black,
tertiary = Color.White,
onTertiary = Color.Black,
tertiaryContainer = Color.White,
onTertiaryContainer = Color.Black,
background = Color.White,
onBackground = Color.Black,
surface = Color.White,
onSurface = Color.Black,
surfaceVariant = Color.White,
onSurfaceVariant = Color.Black,
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color.White,
surfaceContainer = Color.White,
surfaceContainerHigh = Color.White,
surfaceContainerHighest = Color.White,
inverseSurface = Color.Black,
inverseOnSurface = Color.White,
inversePrimary = Color.Black,
outline = Color.Gray,
outlineVariant = Color.Gray,
scrim = Color.Black,
primary = Color(0xFF000000L + 1),
onPrimary = Color(0xFF000000L + 2),
primaryContainer = Color(0xFF000000L + 3),
onPrimaryContainer = Color(0xFF000000L + 4),
secondary = Color(0xFF000000L + 5),
onSecondary = Color(0xFF000000L + 6),
secondaryContainer = Color(0xFF000000L + 7),
onSecondaryContainer = Color(0xFF000000L + 8),
tertiary = Color(0xFF000000L + 9),
onTertiary = Color(0xFF000000L + 10),
tertiaryContainer = Color(0xFF000000L + 11),
onTertiaryContainer = Color(0xFF000000L + 12),
background = Color(0xFF000000L + 13),
onBackground = Color(0xFF000000L + 14),
surface = Color(0xFF000000L + 15),
onSurface = Color(0xFF000000L + 16),
surfaceVariant = Color(0xFF000000L + 17),
onSurfaceVariant = Color(0xFF000000L + 18),
surfaceDim = Color(0xFF000000L + 19),
surfaceBright = Color(0xFF000000L + 20),
primaryFixed = Color(0xFF000000L + 21),
primaryFixedDim = Color(0xFF000000L + 22),
onPrimaryFixed = Color(0xFF000000L + 23),
onPrimaryFixedVariant = Color(0xFF000000L + 24),
secondaryFixed = Color(0xFF000000L + 25),
secondaryFixedDim = Color(0xFF000000L + 26),
onSecondaryFixed = Color(0xFF000000L + 27),
onSecondaryFixedVariant = Color(0xFF000000L + 28),
tertiaryFixed = Color(0xFF000000L + 29),
tertiaryFixedDim = Color(0xFF000000L + 30),
onTertiaryFixed = Color(0xFF000000L + 31),
onTertiaryFixedVariant = Color(0xFF000000L + 32),
surfaceContainerLowest = Color(0xFF000000L + 33),
surfaceContainerLow = Color(0xFF000000L + 34),
surfaceContainer = Color(0xFF000000L + 35),
surfaceContainerHigh = Color(0xFF000000L + 36),
surfaceContainerHighest = Color(0xFF000000L + 37),
inverseSurface = Color(0xFF000000L + 38),
inverseOnSurface = Color(0xFF000000L + 39),
inversePrimary = Color(0xFF000000L + 40),
outline = Color(0xFF000000L + 41),
outlineVariant = Color(0xFF000000L + 42),
scrim = Color(0xFF000000L + 43),
status =
HermesStatusColors(
success = Color.Green,
successContainer = Color.White,
onSuccess = Color.Black,
warning = Color.Yellow,
warningContainer = Color.White,
onWarning = Color.Black,
error = Color.Red,
errorContainer = Color.White,
onError = Color.Black,
onErrorContainer = Color.Black,
info = Color.Blue,
infoContainer = Color.White,
onInfo = Color.Black,
success = Color(0xFF000000L + 100),
successContainer = Color(0xFF000000L + 101),
onSuccess = Color(0xFF000000L + 102),
warning = Color(0xFF000000L + 103),
warningContainer = Color(0xFF000000L + 104),
onWarning = Color(0xFF000000L + 105),
error = Color(0xFF000000L + 106),
errorContainer = Color(0xFF000000L + 107),
onError = Color(0xFF000000L + 108),
onErrorContainer = Color(0xFF000000L + 109),
info = Color(0xFF000000L + 110),
infoContainer = Color(0xFF000000L + 111),
onInfo = Color(0xFF000000L + 112),
),
)
}
@@ -0,0 +1,62 @@
package com.m57.hermescontrol.ui.chat
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 BrowserReturnTrackerTest {
@Test
fun `browser return requires a departure and consumes the lease once`() {
val tracker = BrowserReturnTracker()
tracker.start("operation-1")
assertNull(tracker.onResume())
tracker.onPause()
assertEquals("operation-1", tracker.onResume()?.operationId)
assertNull(tracker.onResume())
assertFalse(tracker.abandon())
}
@Test
fun `legacy browser without operation id still reports return`() {
val tracker = BrowserReturnTracker()
tracker.start()
tracker.onPause()
assertTrue(tracker.onResume() != null)
assertNull(tracker.onResume())
}
@Test
fun `failed launch clears tracking and does not release a departed lease`() {
val tracker = BrowserReturnTracker()
tracker.start("operation-1")
tracker.onPause()
tracker.cancel()
assertNull(tracker.onResume())
assertFalse(tracker.abandon())
}
@Test
fun `non configuration disposal releases only a departed lease and clears the id`() {
val tracker = BrowserReturnTracker()
tracker.start("operation-1")
assertFalse(tracker.abandon())
tracker.start("operation-2")
tracker.onPause()
assertTrue(tracker.abandon())
assertFalse(tracker.abandon())
assertNull(tracker.onResume())
}
@Test
fun `new launch resets departed state from the previous launch`() {
val tracker = BrowserReturnTracker()
tracker.start("first")
tracker.onPause()
tracker.start("second")
assertNull(tracker.onResume())
tracker.onPause()
assertEquals("second", tracker.onResume()?.operationId)
}
}
@@ -1,7 +1,10 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.TypedRpcSender
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -9,6 +12,8 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
@@ -25,7 +30,7 @@ class ChatApprovalsDelegateTest {
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1"))
private var runtimeId: String? = null
private val sentMethods = mutableListOf<String>()
private val sentParams = mutableListOf<Map<String, Any>>()
private val sentParams = mutableListOf<JsonObject>()
private val trackedRequests = mutableListOf<Pair<String, String>>()
private val systemMessages = mutableListOf<String>()
@@ -35,11 +40,22 @@ class ChatApprovalsDelegateTest {
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { method, params, onSent ->
sentMethods.add(method)
sentParams.add(params)
onSent?.invoke("req-id-${sentMethods.size}")
},
rpc =
object : TypedRpcSender {
override fun <P> send(
method: RpcMethod<P, *>,
params: P,
onSent: ((String) -> Unit)?,
): String {
sentMethods.add(method.name)
sentParams.add(
OkHttpProvider.json.encodeToJsonElement(method.params, params) as JsonObject,
)
val id = "req-id-${sentMethods.size}"
onSent?.invoke(id)
return id
}
},
trackRequest = { id, method -> trackedRequests.add(id to method) },
addSystemMessage = { text -> systemMessages.add(text) },
)
@@ -98,8 +114,8 @@ class ChatApprovalsDelegateTest {
assertFalse(uiState.value.isAgentTyping)
assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods)
assertEquals("runtime-session-xyz", sentParams.first()["session_id"])
assertEquals("req-123", sentParams.first()["request_id"])
assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams.first()["session_id"])
assertEquals(JsonPrimitive("req-123"), sentParams.first()["request_id"])
}
@Test
@@ -125,11 +141,11 @@ class ChatApprovalsDelegateTest {
advanceUntilIdle()
assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods)
assertEquals("once", sentParams[0]["choice"])
assertEquals("req-999", sentParams[0]["request_id"])
assertEquals(false, sentParams[0]["all"])
assertEquals("runtime-session-xyz", sentParams[0]["session_id"])
assertEquals("runtime-session-xyz", sentParams[1]["session_id"])
assertEquals(JsonPrimitive("once"), sentParams[0]["choice"])
assertEquals(JsonPrimitive("req-999"), sentParams[0]["request_id"])
assertEquals(JsonPrimitive(false), sentParams[0]["all"])
assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[0]["session_id"])
assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[1]["session_id"])
}
@Test
@@ -270,7 +286,14 @@ class ChatApprovalsDelegateTest {
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { "session-1" },
wsSend = { _, _, _ -> error("new approval must not use legacy response") },
rpc =
object : TypedRpcSender {
override fun <P> send(
method: RpcMethod<P, *>,
params: P,
onSent: ((String) -> Unit)?,
): String = error("new approval must not use legacy response")
},
trackRequest = { _, _ -> },
addSystemMessage = { },
respondToServerRequest = { id, result -> responses += id to result },
@@ -201,7 +201,7 @@ class ChatCompressRpcTest {
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(JsonPrimitive(sessionId), params["session_id"])
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
}
@@ -224,8 +224,8 @@ class ChatCompressRpcTest {
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"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
assertEquals(JsonPrimitive("auth decisions"), params["focus_topic"])
}
@Test
@@ -247,7 +247,7 @@ class ChatCompressRpcTest {
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(JsonPrimitive(sessionId), params["session_id"])
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
}
@@ -271,8 +271,8 @@ class ChatCompressRpcTest {
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"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
assertEquals(JsonPrimitive("architecture details"), params["focus_topic"])
}
@Test
@@ -6,8 +6,13 @@ import com.m57.hermescontrol.data.model.ConnectionOperationTarget
import com.m57.hermescontrol.data.model.ConnectionTargetAction
import com.m57.hermescontrol.data.model.ConnectionTargetKind
import com.m57.hermescontrol.data.model.ConnectionTargetState
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -15,6 +20,9 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -109,19 +117,65 @@ class ChatConnectionOperationDelegateTest {
)
assertEquals(1, requester.calls.size)
val call = requester.calls.single()
assertEquals(WsMethods.CONNECTION_RESPOND, call.first)
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>
val action = requester.calls.single()
assertTrue(action is ConnectionOperationRequest.Respond)
val respond = action as ConnectionOperationRequest.Respond
assertEquals(
ConnectionRespondParams(
owner = ConnectorOwner.session("session-a"),
opId = "op-a",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "approved",
env = mapOf("TOKEN" to canary),
),
),
),
),
respond.params,
)
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), respond.params)
val expectedWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "approved")
put(
"env",
buildJsonObject {
put("TOKEN", canary)
},
)
},
)
},
)
},
)
}
assertEquals(expectedWire, encoded)
@Suppress("UNCHECKED_CAST")
val answer = (result["targets"] as List<Map<String, Any>>).single()
assertEquals("github", answer["name"])
assertEquals("approved", answer["status"])
assertEquals(mapOf("TOKEN" to canary), answer["env"])
assertFalse(
delegate.state.value
.toString()
@@ -166,12 +220,57 @@ class ChatConnectionOperationDelegateTest {
skipDelegate.acceptRequest(snapshot())
skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false)
@Suppress("UNCHECKED_CAST")
val skipResult = skipRequester.calls.single().second["result"] as Map<String, Any>
val skipAction = skipRequester.calls.single()
assertTrue(skipAction is ConnectionOperationRequest.Respond)
val skipRespond = skipAction as ConnectionOperationRequest.Respond
assertEquals(
ConnectionRespondParams(
owner = ConnectorOwner.session("session-a"),
opId = "op-a",
result =
ConnectionAnswer(
targets =
listOf(
ConnectionAnswerTarget(
name = "github",
status = "skipped",
),
),
),
),
skipRespond.params,
)
@Suppress("UNCHECKED_CAST")
val skipAnswer = (skipResult["targets"] as List<Map<String, Any>>).single()
assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer)
val encodedSkip =
OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), skipRespond.params)
val expectedSkipWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
put(
"result",
buildJsonObject {
put(
"targets",
buildJsonArray {
add(
buildJsonObject {
put("name", "github")
put("status", "skipped")
},
)
},
)
},
)
}
assertEquals(expectedSkipWire, encodedSkip)
val continueRequester = RecordingRequester()
val continueDelegate = delegate(continueRequester)
@@ -179,14 +278,38 @@ class ChatConnectionOperationDelegateTest {
continueDelegate.acceptRequest(snapshot())
continueDelegate.continueOperation()
val continueAction = continueRequester.calls.single()
assertTrue(continueAction is ConnectionOperationRequest.Respond)
val continueRespond = continueAction as ConnectionOperationRequest.Respond
assertEquals(
mapOf(
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
"op_id" to "op-a",
"result" to mapOf("settled_by" to "continue"),
ConnectionRespondParams(
owner = ConnectorOwner.session("session-a"),
opId = "op-a",
result = ConnectionAnswer(settledBy = "continue"),
),
continueRequester.calls.single().second,
continueRespond.params,
)
val encodedContinue =
OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), continueRespond.params)
val expectedContinueWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
put(
"result",
buildJsonObject {
put("settled_by", "continue")
},
)
}
assertEquals(expectedContinueWire, encodedContinue)
}
@Test
@@ -199,15 +322,31 @@ class ChatConnectionOperationDelegateTest {
delegate.wake("op-a")
val call = requester.calls.single()
assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first)
val action = requester.calls.single()
assertTrue(action is ConnectionOperationRequest.Wake)
val wake = action as ConnectionOperationRequest.Wake
assertEquals(
mapOf(
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
"op_id" to "op-a",
ConnectorsOperationStatusParams(
owner = ConnectorOwner.session("session-a"),
opId = "op-a",
),
call.second,
wake.params,
)
val encoded =
OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), wake.params)
val expectedWire =
buildJsonObject {
put(
"owner",
buildJsonObject {
put("type", "session")
put("session_id", "session-a")
},
)
put("op_id", "op-a")
}
assertEquals(expectedWire, encoded)
}
@Test
@@ -265,7 +404,7 @@ class ChatConnectionOperationDelegateTest {
runTest {
val gate = CompletableDeferred<Unit>()
val requester =
ConnectionOperationRequester { _, _ ->
ConnectionOperationRequester {
gate.await()
throw IllegalStateException("old failure")
}
@@ -355,13 +494,10 @@ class ChatConnectionOperationDelegateTest {
private class RecordingRequester(
private val failure: Throwable? = null,
) : ConnectionOperationRequester {
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
val calls = mutableListOf<ConnectionOperationRequest>()
override suspend fun request(
method: String,
params: Map<String, Any>,
): Any? {
calls += method to params
override suspend fun request(action: ConnectionOperationRequest): Any? {
calls += action
failure?.let { throw it }
return Unit
}
@@ -0,0 +1,34 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.local.canonicalMessageOrder
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Issue #1336: one owner for the REST row-id format and the known display kinds. */
class ChatMessageIdentityTest {
@Test
fun restIdsRoundTripThroughTheSingleFormatOwner() {
val id = RestMessageId.of("s-1", 42)
assertEquals("rest-s-1-42", id)
assertTrue(RestMessageId.isRest(id))
assertFalse(RestMessageId.isRest("ws-42"))
assertEquals(42L, canonicalMessageOrder(id, "s-1"))
assertNull(canonicalMessageOrder(RestMessageId.of("other", 42), "s-1"))
assertEquals(id, ChatMessage(id = id, role = MessageRole.USER, content = "").canonicalRestId)
}
@Test
fun onlyUnknownOrMarkerKindsAreTimelineMarkers() {
fun kind(value: String?) = ChatMessage(role = MessageRole.USER, content = "x", displayKind = value)
DisplayKind.nonMarkerKinds.forEach { assertFalse(it, kind(it).isTimelineMarker()) }
assertTrue(kind(DisplayKind.MODEL_SWITCH).isTimelineMarker())
assertTrue(kind("future_backend_kind").isTimelineMarker())
assertFalse(kind(null).isTimelineMarker())
}
}
@@ -8,7 +8,7 @@ import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,6 +18,8 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -31,8 +33,7 @@ class ChatModelSwitchDelegateTest {
private val uiState =
MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3"))
private var runtimeId: String? = "runtime-1"
private val sentMethods = mutableListOf<String>()
private val sentParams = mutableListOf<Map<String, Any>>()
private val sentParams = mutableListOf<ConfigSetParams>()
private val slashCommands = mutableListOf<String>()
private val assistantMessages = mutableListOf<String>()
private var contextRefetched = 0
@@ -71,10 +72,9 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { method, params, onSent ->
sentMethods.add(method)
wsSend = { params, onSent ->
sentParams.add(params)
onSent?.invoke("req-${sentMethods.size}")
onSent?.invoke("req-${sentParams.size}")
},
trackRequest = { _, _ -> },
addAssistantMessage = { assistantMessages.add(it) },
@@ -304,8 +304,9 @@ class ChatModelSwitchDelegateTest {
delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session")
advanceUntilIdle()
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"])
assertEquals(1, sentParams.size)
assertEquals("model", sentParams.first().key)
assertEquals("gpt-4o --provider openai --session", sentParams.first().value)
delegate.handleConfigSetResult(
id = "req-1",
@@ -320,8 +321,8 @@ class ChatModelSwitchDelegateTest {
delegate.confirmModelSwitchExpensive()
advanceUntilIdle()
assertEquals(2, sentMethods.size)
assertEquals(true, sentParams[1]["confirm_expensive_model"])
assertEquals(2, sentParams.size)
assertEquals(true, sentParams[1].confirmExpensiveModel)
}
@Test
@@ -366,7 +367,6 @@ class ChatModelSwitchDelegateTest {
// Switch to model with fast capability
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
sentMethods.clear()
sentParams.clear()
assertTrue(uiState.value.currentModelCapabilities?.fast == true)
@@ -377,10 +377,10 @@ class ChatModelSwitchDelegateTest {
advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging)
assertFalse(uiState.value.fastMode) // Still unconfirmed!
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
assertEquals("fast", sentParams.first()["key"])
assertEquals("fast", sentParams.first()["value"])
assertEquals("runtime-1", sentParams.first()["session_id"])
assertEquals(1, sentParams.size)
assertEquals("fast", sentParams.first().key)
assertEquals("fast", sentParams.first().value)
assertEquals("runtime-1", sentParams.first().sessionId)
// Backend acknowledges
delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast"))
@@ -391,7 +391,7 @@ class ChatModelSwitchDelegateTest {
delegate.toggleFastMode()
advanceUntilIdle()
assertTrue(uiState.value.isFastModeChanging)
assertEquals("normal", sentParams.last()["value"])
assertEquals("normal", sentParams.last().value)
delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal"))
assertFalse(uiState.value.fastMode)
@@ -405,7 +405,6 @@ class ChatModelSwitchDelegateTest {
advanceUntilIdle()
delegate.sendSlashModel("openai", "gpt-4o")
advanceUntilIdle()
sentMethods.clear()
sentParams.clear()
assertTrue(uiState.value.currentModelCapabilities?.fast == true)
@@ -420,10 +419,10 @@ class ChatModelSwitchDelegateTest {
assertEquals(false, uiState.value.currentModelCapabilities?.fast)
// Further toggle attempts are no-ops
val sentCount = sentMethods.size
val sentCount = sentParams.size
delegate.toggleFastMode()
advanceUntilIdle()
assertEquals(sentCount, sentMethods.size)
assertEquals(sentCount, sentParams.size)
}
@Test
@@ -470,9 +469,8 @@ class ChatModelSwitchDelegateTest {
@Test
fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() =
testScope.runTest {
val gate = CompletableDeferred<Any?>()
val reqMethods = mutableListOf<String>()
val reqParams = mutableListOf<Map<String, Any>>()
val gate = CompletableDeferred<JsonElement>()
val reqParams = mutableListOf<ConfigSetParams>()
val customDelegate =
ChatModelSwitchDelegate(
@@ -480,14 +478,13 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
wsSend = { _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { method, params ->
reqMethods.add(method)
wsRequest = { params ->
reqParams.add(params)
gate.await()
},
@@ -503,14 +500,15 @@ class ChatModelSwitchDelegateTest {
assertEquals("ultra", uiState.value.reasoningLevel)
assertEquals("high", uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningWireLevel)
assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods)
assertEquals("session", reqParams.first()["scope"])
assertEquals("high", reqParams.first()["value"])
assertEquals(1, reqParams.size)
assertEquals("reasoning", reqParams.first().key)
assertEquals("session", reqParams.first().scope)
assertEquals("high", reqParams.first().value)
// Second pick while in-flight is rejected (serialized)
customDelegate.setReasoningLevel("low")
runCurrent()
assertEquals(1, reqMethods.size)
assertEquals(1, reqParams.size)
// Simulate session.info winning the race and publishing the fresh
// authoritative wire before the RPC ACK resumes the delegate.
@@ -540,13 +538,13 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
wsSend = { _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") },
wsRequest = { throw RuntimeException("4002 unknown reasoning value") },
)
customDelegate.setReasoningLevel("ultra")
@@ -560,7 +558,7 @@ class ChatModelSwitchDelegateTest {
@Test
fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() =
testScope.runTest {
val reqMethods = mutableListOf<String>()
val reqParams = mutableListOf<ConfigSetParams>()
runtimeId = null
val customDelegate =
@@ -569,22 +567,22 @@ class ChatModelSwitchDelegateTest {
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
wsSend = { _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { method, _ ->
reqMethods.add(method)
null
wsRequest = { params ->
reqParams.add(params)
JsonNull
},
)
customDelegate.setReasoningLevel("high")
advanceUntilIdle()
assertTrue(reqMethods.isEmpty())
assertTrue(reqParams.isEmpty())
assertNull(uiState.value.pendingReasoningLevel)
assertNull(uiState.value.reasoningLevel)
}
@@ -592,20 +590,20 @@ class ChatModelSwitchDelegateTest {
@Test
fun setReasoningLevel_staleScopeOrReset_dropsLateAck() =
testScope.runTest {
val gate = CompletableDeferred<Any?>()
val gate = CompletableDeferred<JsonElement>()
val customDelegate =
ChatModelSwitchDelegate(
scope = testScope,
ioDispatcher = testDispatcher,
uiState = uiState,
runtimeSessionId = { runtimeId },
wsSend = { _, _, _ -> },
wsSend = { _, _ -> },
trackRequest = { _, _ -> },
addAssistantMessage = {},
handleSlashCommand = {},
fetchContextUsage = {},
dataScopeFlow = dataScope,
wsRequest = { _, _ -> gate.await() },
wsRequest = { gate.await() },
)
customDelegate.setReasoningLevel("high")
@@ -617,7 +615,12 @@ class ChatModelSwitchDelegateTest {
assertNull(uiState.value.pendingReasoningLevel)
// Late RPC response arrives
gate.complete(mapOf("key" to "reasoning", "value" to "high"))
gate.complete(
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
},
)
advanceUntilIdle()
assertNull(uiState.value.reasoningLevel)
@@ -952,6 +952,100 @@ class ChatPagingMergeTest {
)
}
private fun laterTurnRows(assistantText: String) =
listOf(
SessionMessage(
id = 0,
role = "user",
content = JsonPrimitive("long running task"),
timestamp = JsonPrimitive(1),
),
SessionMessage(
id = 1,
role = "assistant",
content = JsonPrimitive(assistantText),
timestamp = JsonPrimitive(2),
),
SessionMessage(
id = 2,
role = "user",
content = JsonPrimitive("next question"),
timestamp = JsonPrimitive(3),
),
SessionMessage(
id = 3,
role = "assistant",
content = JsonPrimitive("next answer"),
timestamp = JsonPrimitive(4),
),
)
@Test
fun stopNoticesStayInPlaceWhenInterruptedReplyIsStillLiveOnly() {
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "Working on it...")
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
val processes =
ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "Stopped 2 background processes.")
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
val merged =
applyServerPage(listOf(prompt, liveReply, stop, processes, interrupted), laterTurnRows("Working on it..."))
assertEquals(
listOf(
"rest-session-0",
"rest-session-1",
"uuid-stop",
"uuid-procs",
"uuid-int",
"rest-session-2",
"rest-session-3",
),
merged.map { it.canonicalRestId ?: it.id },
)
}
@Test
fun stopNoticesStayInPlaceWhenLongInterruptedReplyIsAPrefixOfTheServerCopy() {
val long = "Working on the migration plan step by step, first the schema then the data"
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = long)
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
val merged = applyServerPage(listOf(prompt, liveReply, stop, interrupted), laterTurnRows("$long, then indexes"))
val ids = merged.map { it.canonicalRestId ?: it.id }
assertTrue("notices before later turns: $ids", ids.indexOf("uuid-int") < ids.indexOf("rest-session-2"))
}
@Test
fun processResultArrivingAfterInterruptNoticeStaysBeforeLaterTurns() {
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
val reply = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...")
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
val processes =
ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "No background processes to stop.")
val merged =
applyServerPage(listOf(prompt, reply, stop, interrupted, processes), laterTurnRows("Working on it..."))
assertEquals(
listOf(
"rest-session-0",
"rest-session-1",
"uuid-stop",
"uuid-int",
"uuid-procs",
"rest-session-2",
"rest-session-3",
),
merged.map { it.canonicalRestId ?: it.id },
)
}
@Test
fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() {
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?")
@@ -1115,4 +1209,59 @@ class ChatPagingMergeTest {
chronological = !older,
preserveLiveIds = true,
)
@Test
fun restoredLocalCommandsKeepTheirChronologicalPlaceInCachedPage() {
fun row(
id: String,
role: MessageRole,
content: String,
ts: Long,
local: Long? = null,
) = ChatMessage(id = id, role = role, content = content, timestamp = ts, localOrder = local)
// Room order: confirmed rows first, then every local row (sort_group 1).
val cachedPage =
listOf(
row("rest-s-1", MessageRole.USER, "hi", 10L),
row("rest-s-2", MessageRole.ASSISTANT, "hello", 11L),
row("rest-s-3", MessageRole.USER, "more", 30L),
row("rest-s-4", MessageRole.ASSISTANT, "ok", 31L),
row("rest-s-5", MessageRole.USER, "again", 50L),
row("rest-s-6", MessageRole.ASSISTANT, "sure", 51L),
row("cmd-a", MessageRole.USER, "/help", 12L, local = 1L),
row("cmd-b", MessageRole.USER, "/usage", 32L, local = 2L),
row("cmd-c", MessageRole.USER, "/model", 52L, local = 3L),
)
val merged = mergeCachedTranscriptPage(cachedPage, emptyList())
assertEquals(
listOf("rest-s-1", "rest-s-2", "cmd-a", "rest-s-3", "rest-s-4", "cmd-b", "rest-s-5", "rest-s-6", "cmd-c"),
merged.map { it.id },
)
}
@Test
fun restoredCommandOlderThanLoadedWindowStaysAboveItNotAtTheTail() {
val server =
listOf(
ChatMessage(id = "rest-s-50", role = MessageRole.USER, content = "a", timestamp = 500L),
ChatMessage(id = "rest-s-51", role = MessageRole.ASSISTANT, content = "b", timestamp = 501L),
)
val old =
ChatMessage(
id = "cmd",
role = MessageRole.USER,
content = "/model x",
timestamp = 100L,
localOrder = 9L,
)
val merged = mergeCachedTranscriptPage(server + old, emptyList())
assertEquals(listOf("cmd", "rest-s-50", "rest-s-51"), merged.map { it.id })
// Stable when an older page arrives afterwards.
val older = ChatMessage(id = "rest-s-10", role = MessageRole.USER, content = "o", timestamp = 50L)
val olderMid = ChatMessage(id = "rest-s-11", role = MessageRole.ASSISTANT, content = "p", timestamp = 150L)
val more = mergeCachedTranscriptPage(listOf(older, olderMid), merged)
assertEquals(listOf("rest-s-10", "cmd", "rest-s-11", "rest-s-50", "rest-s-51"), more.map { it.id })
}
}
@@ -0,0 +1,162 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Issue #1335: prefix dedupe must not merge distinct messages.
*/
class ChatPrefixDedupeTest {
// ── Contract 1 ────────────────────────────────────────────────────────────
// Two USER messages, both >=40 chars, one a strict prefix of the other,
// different ids -> NOT the same logical message (both survive a merge).
@Test
fun contract1_userMessages_strictPrefixDistinctIds_notSameLogicalMessage() {
val prefixText = "Please write a comprehensive test suite for issue thirteen thirty five."
val fullText = "$prefixText It should cover all edge cases thoroughly."
val msg1 = ChatMessage(id = "user-msg-1", role = MessageRole.USER, content = prefixText)
val msg2 = ChatMessage(id = "user-msg-2", role = MessageRole.USER, content = fullText)
assertFalse(sameLogicalMessage(msg1, msg2))
assertFalse(sameLogicalMessage(msg2, msg1))
val merged = mergeTranscriptWithLive(listOf(msg2), listOf(msg1), preserveLiveIds = true)
assertEquals(2, merged.size)
assertTrue(merged.any { it.id == "user-msg-1" && it.content == prefixText })
assertTrue(merged.any { it.id == "user-msg-2" && it.content == fullText })
}
// ── Contract 2 ────────────────────────────────────────────────────────────
// Two ASSISTANT messages that are both canonical REST rows (distinct restIds /
// canonical ids), one a >=40-char prefix of the other -> NOT same.
@Test
fun contract2_assistantCanonicalRestRows_strictPrefix_notSameLogicalMessage() {
val prefixText = "I have inspected the project repository and verified the build files."
val fullText = "$prefixText Now proceeding with the implementation of the requested tests."
val restRow1 =
ChatMessage(
id = "rest-s-101",
role = MessageRole.ASSISTANT,
content = prefixText,
restId = "rest-s-101",
)
val restRow2 =
ChatMessage(
id = "rest-s-102",
role = MessageRole.ASSISTANT,
content = fullText,
restId = "rest-s-102",
)
assertFalse(sameLogicalMessage(restRow1, restRow2))
assertFalse(sameLogicalMessage(restRow2, restRow1))
val merged = mergeTranscriptWithLive(listOf(restRow2), listOf(restRow1), preserveLiveIds = true)
assertEquals(2, merged.size)
}
// ── Contract 3 ────────────────────────────────────────────────────────────
// TOOL rows never match by content prefix.
@Test
fun contract3_toolRows_neverMatchByContentPrefix() {
val prefixToolContent = """{"output":"build succeeded without any compiler errors or warnings during check"}"""
val fullToolContent =
"""{"output":"build succeeded without any compiler errors or warnings during check and all tests pass"}"""
val tool1 =
ChatMessage(
id = "tool-1",
role = MessageRole.TOOL,
content = prefixToolContent,
toolName = "terminal",
toolCallId = "call-1",
)
val tool2 =
ChatMessage(
id = "tool-2",
role = MessageRole.TOOL,
content = fullToolContent,
toolName = "terminal",
toolCallId = "call-2",
)
assertFalse(sameLogicalMessage(tool1, tool2))
assertFalse(sameLogicalMessage(tool2, tool1))
}
// ── Contract 4 ────────────────────────────────────────────────────────────
// Exact-equal trimmed content still matches as before for a live vs REST pair
// of the same role (sanity).
@Test
fun contract4_exactEqualTrimmedContent_liveVsRest_matchesSameLogicalMessage() {
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT)) {
val live =
ChatMessage(
id = "live-temp-id",
role = role,
content = "\n\nHello, this is an important message for testing.\n ",
)
val rest =
ChatMessage(
id = "rest-s-200",
role = role,
content = "Hello, this is an important message for testing.",
restId = "rest-s-200",
)
assertTrue("Role $role exact-equal trimmed content must match", sameLogicalMessage(live, rest))
assertTrue(
"Role $role exact-equal trimmed content must match symmetrically",
sameLogicalMessage(rest, live),
)
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live), preserveLiveIds = true)
assertEquals("Exact-equal trimmed pair should deduplicate into single message", 1, merged.size)
}
}
// ── Contract 5 ────────────────────────────────────────────────────────────
// Issue #842 scenario still dedupes: a live sealed interim assistant narration
// without completion/REST identity whose text (after trim) is a prefix of /
// equal-prefix to the persisted REST narration -> treated as same (reuse the
// shape used in ChatHistoryCommentaryTest if it exists).
@Test
fun contract5_issue842_liveSealedInterimAssistantNarration_prefixOfRestNarration_stillDedupes() {
// Issue #842 / ChatToolDedupeTest pattern:
// Live sealed interim bubble without REST identity raced flush and ends prematurely.
// Rest row contains the full/completed narration.
val liveOrphan =
ChatMessage(
id = "live-orphan-sealed",
role = MessageRole.ASSISTANT,
content =
"\n\ntool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
" — gimme dat creamy dreamy chickpea",
)
val restRow =
ChatMessage(
id = "rest-s-842",
role = MessageRole.ASSISTANT,
content =
"tool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
" — gimme dat creamy dreamy chickpea goodness:",
restId = "rest-s-842",
)
assertTrue(sameLogicalMessage(liveOrphan, restRow))
assertTrue(sameLogicalMessage(restRow, liveOrphan))
val merged = mergeTranscriptWithLive(listOf(restRow), listOf(liveOrphan), preserveLiveIds = true)
assertEquals(
"Live interim orphan should deduplicate against persisted REST narration",
1,
merged.size,
)
}
}
@@ -34,15 +34,25 @@ class ChatSearchControllerTest {
}
@Test
fun `excludes tool rows even when the payload contains the query`() {
fun `finds visible tool name without indexing hidden raw payload`() {
val messages =
listOf(
message(MessageRole.USER, "check the logs"),
message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"),
message(MessageRole.ASSISTANT, "all good"),
message(MessageRole.TOOL, "unrendered private payload").copy(toolName = "terminal"),
)
assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "check the logs")))
assertEquals(listOf(SearchMatch(1, 0, SearchTarget.TOOL)), hits(controller.findMatches(messages, "terminal")))
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "private payload")))
}
@Test
fun `system marker carried as user does not yield an invisible hit`() {
val marker =
message(
MessageRole.USER,
"You've reached the maximum number of tool-calling iterations allowed.",
)
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(listOf(marker), "maximum")))
}
@Test
@@ -57,7 +67,7 @@ class ChatSearchControllerTest {
}
@Test
fun `excludes reasoning text that never made it to visible content`() {
fun `finds reasoning text independently of assistant prose`() {
val messages =
listOf(
message(
@@ -67,8 +77,10 @@ class ChatSearchControllerTest {
),
)
// Only the visible content is searchable — reasoning is invisible.
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "deployment")))
assertEquals(
listOf(SearchMatch(0, 19, SearchTarget.REASONING)),
hits(controller.findMatches(messages, "deployment")),
)
assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer")))
}
@@ -139,6 +139,39 @@ class ChatSearchDelegateTest {
assertEquals(0, delegate.searchState.currentIndex)
}
@Test
fun `search tracks target kind while navigating reasoning tool and prose`() =
runTest {
val uiState =
MutableStateFlow(
ChatUiState(
messages =
listOf(
ChatMessage("m0", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
ChatMessage("m1", MessageRole.TOOL, "opaque", toolName = "thinking_tool"),
),
),
)
val delegate =
ChatSearchDelegate(
scope = backgroundScope,
uiState = uiState,
dispatcher = StandardTestDispatcher(testScheduler),
debounceMs = 150,
)
delegate.setSearchQuery("thinking")
advanceTimeBy(150)
runCurrent()
assertEquals(listOf(SearchTarget.REASONING, SearchTarget.TOOL), delegate.searchState.matchTargets)
assertEquals("m0", delegate.searchState.currentMatchId)
delegate.navigateSearchMatch(1)
assertEquals("m1", delegate.searchState.currentMatchId)
assertEquals(SearchTarget.TOOL, delegate.searchState.matchTargets[delegate.searchState.currentIndex])
delegate.clearSearch()
assertEquals(emptyList<SearchTarget>(), delegate.searchState.matchTargets)
}
@Test
fun `clearSearch resets everything including matched sets`() =
runTest {
@@ -0,0 +1,97 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.MessageReaction
import com.m57.hermescontrol.data.model.SessionMessage
import com.m57.hermescontrol.notification.ReplyNotificationTarget
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Issue #1337: the REST mapper runs on plain inputs, with no Android, AuthManager, or tracker state. */
class ChatServerMessageMapperPurityTest {
private fun assistant(
id: Int,
text: String,
) = SessionMessage(id = id, role = "assistant", content = JsonPrimitive(text))
@Test
fun mediaAttachmentsUseTheInjectedUrlBuilder() {
val mapped =
mapServerMessages(
"s",
listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")),
0,
true,
emptyList(),
mediaUrl = { path -> "gw://$path" },
).single()
assertEquals("Chart", mapped.content.trim())
assertEquals(listOf("gw:///opt/hermes/chart.png"), mapped.attachments?.map { it.uri })
}
@Test
fun mediaWithoutAGatewayStripsTheTokenAndAddsNoAttachment() {
val mapped =
mapServerMessages("s", listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")), 0, true, emptyList())
.single()
assertEquals("Chart", mapped.content.trim())
assertNull(mapped.attachments)
}
@Test
fun activeReplyTargetIsAPlainInputScopedToItsSession() {
val rows = listOf(assistant(100, "Done"), assistant(200, "Done"))
val target = ReplyNotificationTarget("default", "s", "comp", 1L, serverMessageId = 100)
val bound = mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target)
val otherSession =
mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target.copy(sessionId = "x"))
assertEquals(listOf("comp", null), bound.map { it.completionId })
assertEquals(listOf(null, null), otherSession.map { it.completionId })
}
@Test
fun rowsWithoutTimestampsUseTheInjectedClock() {
val mapped = mapServerMessages("s", listOf(assistant(1, "Hi")), 0, true, emptyList(), nowMs = 42L)
assertEquals(42L, mapped.single().timestamp)
}
@Test
fun persistedReactionsHydrateFromDisplayMetadata() {
val row =
SessionMessage(
id = 5,
role = "user",
content = JsonPrimitive("hello"),
display_metadata =
buildJsonObject {
put(
"reactions",
buildJsonArray {
add(
buildJsonObject {
put("emoji", "\uD83D\uDE02")
put("author", "agent")
},
)
add(buildJsonObject { put("author", "user") })
},
)
},
)
val mapped = mapServerMessages("s", listOf(row), 0, true, emptyList()).single()
assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), mapped.reactions)
assertEquals(
emptyList<MessageReaction>(),
mapServerMessages("s", listOf(assistant(6, "x")), 0, true, emptyList()).single().reactions,
)
}
}
@@ -9,11 +9,11 @@ import org.junit.Test
class ChatStreamingControllerTest {
@Test
fun flushPendingReasoning_flushesThrottledReasoningOnTransition() =
fun flushPendingTransition_flushesThrottledReasoning() =
runTest {
// isTestEnvironment = false so the ~33ms throttle is live. The FIRST delta
// always flushes (lastFlushMs == 0), but a SECOND delta within 33ms is held
// in the buffer and only pushed out by flushPendingReasoning() (called at
// in the buffer and only pushed out by flushPendingTransition() (called at
// streaming transitions) — that is the exact drop we guard against.
val uiState = MutableStateFlow(ChatUiState())
val streamingState =
@@ -38,13 +38,13 @@ class ChatStreamingControllerTest {
assertEquals("A", streamingState.value.streamingMessage?.reasoningText)
// A streaming transition forces the buffered reasoning onto the message.
controller.flushPendingReasoning()
controller.flushPendingTransition()
assertEquals("AB", streamingState.value.streamingMessage?.reasoningText)
}
@Test
fun flushPendingTokens_flushesThrottledTokensBeforeSeal() =
fun flushPendingTransition_flushesThrottledTokensBeforeSeal() =
runTest {
// Issue #842: a delta landing <33ms before tool.start stays in the
// throttled buffer; flushing the tokens before the reducer seals the
@@ -73,7 +73,7 @@ class ChatStreamingControllerTest {
// The tool.start transition forces the buffered tokens out so the
// sealed orphan carries the complete narration.
controller.flushPendingTokens()
controller.flushPendingTransition()
assertEquals(
"tool's loaded! 🔍 now searchin' for the best hummus",
@@ -95,7 +95,7 @@ class ChatStreamingControllerTest {
// A fresh reasoning delta + flush must carry ONLY the new value, proving the
// stale buffer was cleared and cannot be resurrected.
controller.handleReasoningDelta(WsEvent.ReasoningDelta("Fresh reasoning", "session"))
controller.flushPendingReasoning()
controller.flushPendingTransition()
assertEquals("Fresh reasoning", streamingState.value.reasoningText)
}
@@ -168,6 +168,24 @@ class ChatStreamingControllerTest {
assertEquals("First Second", streamingState.value.streamingMessage?.content)
}
@Test
fun thinkingDelta_trailingFlushAndResetDoNotLeakIntoNextTurn() =
runTest {
val uiState = MutableStateFlow(ChatUiState())
val streamingState = MutableStateFlow(StreamingState())
val controller = controller(this, uiState, streamingState, isTestEnvironment = { false })
controller.handleThinkingDelta(WsEvent.ThinkingDelta("Think", "session"))
controller.handleThinkingDelta(WsEvent.ThinkingDelta(" more", "session"))
assertEquals("Think", streamingState.value.thinkingText)
testScheduler.advanceTimeBy(35L)
assertEquals("Think more", streamingState.value.thinkingText)
controller.resetStreaming()
controller.handleThinkingDelta(WsEvent.ThinkingDelta("Fresh", "session"))
assertEquals("Fresh", streamingState.value.thinkingText)
}
private fun controller(
scope: CoroutineScope,
uiState: MutableStateFlow<ChatUiState>,
@@ -448,7 +448,7 @@ class ChatSubagentsDelegateTest {
),
)
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returnsMany
listOf(
SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false),
SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false),
@@ -485,7 +485,7 @@ class ChatSubagentsDelegateTest {
),
)
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false)
delegate.inspectSubagentTranscript("sub-1")
@@ -507,7 +507,7 @@ class ChatSubagentsDelegateTest {
advanceTimeBy(3000)
// Verify tailSubagent was called at most once or twice, not indefinitely
coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) }
coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1") }
delegate.closeSubagentTranscript()
}
@@ -526,7 +526,7 @@ class ChatSubagentsDelegateTest {
),
)
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
SubagentTailResponse(subagentId = "sub-1", text = "Output")
delegate.inspectSubagentTranscript("sub-1")
@@ -553,7 +553,7 @@ class ChatSubagentsDelegateTest {
),
)
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } throws
RuntimeException("Gateway connection error")
delegate.inspectSubagentTranscript("sub-1")
@@ -565,7 +565,7 @@ class ChatSubagentsDelegateTest {
assertFalse(transcript?.isLoading == true)
// Now recover on retry
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs")
delegate.retryTranscript()
@@ -35,6 +35,10 @@ import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
import com.m57.hermescontrol.data.ws.contract.ProcessStopResult
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.notification.TurnCorrelationTracker
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
@@ -63,6 +67,7 @@ import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
@@ -280,6 +285,9 @@ class ChatViewModelTest {
HermesWsClient.send(arg(0), arg(1)) {}
CompletableDeferred<Any?>(Unit)
}
// /stop also fires process.stop; default to "nothing to kill" so unrelated /stop tests never hit the socket.
coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns
ProcessStopResult(killed = 0)
mockkObject(ApiClient)
mockkObject(HermesDatabase)
@@ -767,8 +775,8 @@ class ChatViewModelTest {
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"])
assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"])
assertEquals(JsonPrimitive("my-fork"), branchSent.second["name"])
}
@Test
@@ -813,7 +821,7 @@ class ChatViewModelTest {
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(JsonPrimitive(sessionId), branchSent!!.second["session_id"])
assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name"))
}
@@ -876,8 +884,8 @@ class ChatViewModelTest {
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"])
assertEquals(JsonPrimitive("my-retry-branch"), branchWholeSent!!.second["name"])
assertEquals(JsonPrimitive(sessionId), branchWholeSent.second["session_id"])
// Backend answers -32601 unknown method
mockEventsFlow.emit(
@@ -891,8 +899,8 @@ class ChatViewModelTest {
// 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(JsonPrimitive("my-retry-branch"), branchSent!!.second["name"])
assertEquals(JsonPrimitive(sessionId), branchSent.second["session_id"])
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH })
// session.branch result is then handled
@@ -963,6 +971,52 @@ class ChatViewModelTest {
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
}
@Test
fun testSlashCommand_stop_alsoKillsBackgroundProcesses() =
runTest {
val (viewModel, _) = createViewModelWithSession()
coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns
ProcessStopResult(killed = 2)
viewModel.sendMessage("/stop")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
coVerify(exactly = 1) { HermesWsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams, any(), any()) }
val contents =
viewModel.uiState.value.messages
.map { it.content }
assertTrue(contents.contains("Stopped 2 background processes."))
}
@Test
fun testSlashCommand_interrupt_doesNotKillBackgroundProcesses() =
runTest {
val (viewModel, _) = createViewModelWithSession()
viewModel.sendMessage("/interrupt")
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) }
}
@Test
fun testSlashCommand_stop_reportsProcessStopFailure() =
runTest {
val (viewModel, _) = createViewModelWithSession()
coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } throws
IllegalStateException("boom")
viewModel.sendMessage("/stop")
advanceUntilIdle()
assertTrue(
viewModel.uiState.value.messages
.any { it.content == "Could not stop background processes: boom" },
)
}
@Test
fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() =
runTest {
@@ -1038,7 +1092,15 @@ class ChatViewModelTest {
val contents = merged.map { it.content }
assertEquals(
listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"),
listOf(
"start task",
"running...",
"/stop",
"No background processes to stop.",
"Session interrupted",
"next prompt",
"next reply",
),
contents,
)
}
@@ -1077,7 +1139,12 @@ class ChatViewModelTest {
verify {
HermesWsClient.request(
WsMethods.PROMPT_BTW,
mapOf("session_id" to sessionId, "text" to "which file was that in?"),
JsonObject(
mapOf(
"session_id" to JsonPrimitive(sessionId),
"text" to JsonPrimitive("which file was that in?"),
),
),
any(),
)
}
@@ -1289,15 +1356,9 @@ class ChatViewModelTest {
// commands). NOT command.dispatch (4018s on /model), NOT prompt.submit
// (LLM would treat it as text). Capture the config.set params.
val modelCalls = mutableListOf<Triple<String, String, String>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(1)
modelCalls.add(
Triple(
params["key"] as String,
params["value"] as String,
params["session_id"] as String,
),
)
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val params = arg<ConfigSetParams>(1)
modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty()))
"req-cfg-${modelCalls.size}"
}
@@ -1309,7 +1370,7 @@ class ChatViewModelTest {
"openai/gpt-4o",
viewModel.uiState.value.currentSessionModel,
)
verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) }
verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) }
val call = modelCalls.firstOrNull { it.first == "model" }
assertNotNull("selection must route through config.set key=model", call)
assertEquals("gpt-4o --provider openai --session", call!!.second)
@@ -1334,7 +1395,7 @@ class ChatViewModelTest {
// (key="model"), which the gateway routes to _apply_model_switch. NOT
// command.dispatch (4018s on /model) and NOT prompt.submit (LLM would
// treat it as text).
verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) }
verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) }
}
@Test
@@ -1347,15 +1408,9 @@ class ChatViewModelTest {
// slash prefix must be stripped, or parse_model_flags on the
// backend won't recognize it and the hot-swap silently fails.
val modelCalls = mutableListOf<Triple<String, String, String>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(1)
modelCalls.add(
Triple(
params["key"] as String,
params["value"] as String,
params["session_id"] as String,
),
)
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val params = arg<ConfigSetParams>(1)
modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty()))
"req-cfg-ci-${modelCalls.size}"
}
@@ -1382,9 +1437,9 @@ class ChatViewModelTest {
val (viewModel, sessionId) = createViewModelWithSession()
var lastReqId = ""
val capturedParams = mutableListOf<Map<String, Any>>()
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
val params = arg<Map<String, Any>>(1)
val capturedParams = mutableListOf<ConfigSetParams>()
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val params = arg<ConfigSetParams>(1)
capturedParams.add(params)
val id = "req-confirm-${capturedParams.size}"
lastReqId = id
@@ -1397,7 +1452,7 @@ class ChatViewModelTest {
advanceUntilIdle()
assertEquals(1, capturedParams.size)
assertNull(capturedParams[0]["confirm_expensive_model"])
assertNull(capturedParams[0].confirmExpensiveModel)
// Backend returns confirm_required = true
mockEventsFlow.emit(
@@ -1423,12 +1478,12 @@ class ChatViewModelTest {
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
assertEquals(2, capturedParams.size)
assertEquals(true, capturedParams[1]["confirm_expensive_model"])
assertEquals(true, capturedParams[1].confirmExpensiveModel)
assertEquals(
"muse-spark-1.3-contributor-free --provider opencode-free --session",
capturedParams[1]["value"],
capturedParams[1].value,
)
assertEquals(sessionId, capturedParams[1]["session_id"])
assertEquals(sessionId, capturedParams[1].sessionId)
}
@Test
@@ -1437,7 +1492,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession()
var lastReqId = ""
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
val id = "req-cfg-dismiss"
lastReqId = id
arg<((String) -> Unit)?>(2)?.invoke(id)
@@ -1487,7 +1542,11 @@ class ChatViewModelTest {
every {
HermesWsClient.request(
WsMethods.COMMAND_DISPATCH,
mapOf("name" to "undo", "arg" to "2", "session_id" to sessionId),
buildJsonObject {
put("name", "undo")
put("arg", "2")
put("session_id", sessionId)
},
any(),
)
} returns
@@ -1573,7 +1632,12 @@ class ChatViewModelTest {
verify {
HermesWsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to "session-from-notification", "omit_messages" to true),
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-from-notification"),
"omit_messages" to JsonPrimitive(true),
),
),
any(),
)
}
@@ -3858,6 +3922,41 @@ class ChatViewModelTest {
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) }
}
@Test
fun lateCompletionAfterInterruptUpdatesSealedReplyInPlace() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(WsEvent.MessageToken("partial reply", sessionId))
advanceUntilIdle()
val originalId =
viewModel.streamingState.value.streamingMessage!!
.id
viewModel.interruptSession()
advanceUntilIdle()
val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted")))
advanceUntilIdle()
assertNull(viewModel.streamingState.value.streamingMessage)
mockEventsFlow.emit(WsEvent.MessageToken("late tail", sessionId))
advanceUntilIdle()
assertNull(viewModel.streamingState.value.streamingMessage)
mockEventsFlow.emit(WsEvent.MessageComplete("partial reply with final tail", sessionId))
advanceUntilIdle()
val messages = viewModel.uiState.value.messages
val replies = messages.filter { it.role == MessageRole.ASSISTANT }
assertEquals(1, replies.size)
assertEquals(originalId, replies.single().id)
assertEquals("partial reply with final tail", replies.single().content)
assertTrue(
messages.indexOf(replies.single()) < messages.indexOfFirst { it.content == "Session interrupted" },
)
assertNull(viewModel.streamingState.value.interruptedMessage)
}
@Test
fun hardInterruptWaitsForAckBeforeSubmitting() =
runTest {
@@ -4983,7 +5082,12 @@ class ChatViewModelTest {
verify {
HermesWsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to "session-456", "omit_messages" to true),
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"omit_messages" to JsonPrimitive(true),
),
),
any(),
)
}
@@ -4996,7 +5100,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession()
val resumeRequests = mutableMapOf<String, String>()
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
val sessionId = arg<Map<String, String>>(1).getValue("session_id")
val sessionId = (arg<Map<String, Any?>>(1).getValue("session_id") as JsonPrimitive).content
val requestId = "resume-$sessionId"
resumeRequests[sessionId] = requestId
arg<((String) -> Unit)?>(2)?.invoke(requestId)
@@ -5090,7 +5194,7 @@ class ChatViewModelTest {
val (viewModel, _) = createViewModelWithSession()
val resumeRequests = mutableMapOf<String, String>()
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
val sessionId = arg<Map<String, String>>(1).getValue("session_id")
val sessionId = (arg<Map<String, Any?>>(1).getValue("session_id") as JsonPrimitive).content
val requestId = "resume-$sessionId"
resumeRequests[sessionId] = requestId
arg<((String) -> Unit)?>(2)?.invoke(requestId)
@@ -5689,14 +5793,14 @@ class ChatViewModelTest {
verify(exactly = 0) {
HermesWsClient.request(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
match { it["session_id"] == "session-456" },
match { it["session_id"] == JsonPrimitive("session-456") },
any(),
)
}
verify(exactly = 0) {
HermesWsClient.request(
WsMethods.SESSION_USAGE,
match { it["session_id"] == "session-456" },
match { it["session_id"] == JsonPrimitive("session-456") },
any(),
)
}
@@ -5718,18 +5822,18 @@ class ChatViewModelTest {
verify {
HermesWsClient.request(
WsMethods.SESSION_CONTEXT_BREAKDOWN,
match { it["session_id"] == "runtime-456" },
match { it["session_id"] == JsonPrimitive("runtime-456") },
any(),
)
}
verify {
HermesWsClient.request(
WsMethods.SESSION_USAGE,
match { it["session_id"] == "runtime-456" },
match { it["session_id"] == JsonPrimitive("runtime-456") },
any(),
)
}
assertEquals("runtime-456", paramsSlot.captured["session_id"])
assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"])
}
@Test
@@ -5833,7 +5937,12 @@ class ChatViewModelTest {
verify {
HermesWsClient.send(
WsMethods.SESSION_RESUME,
mapOf("session_id" to "session-456", "omit_messages" to true),
JsonObject(
mapOf(
"session_id" to JsonPrimitive("session-456"),
"omit_messages" to JsonPrimitive(true),
),
),
any(),
)
}
@@ -6343,7 +6452,13 @@ class ChatViewModelTest {
viewModel.interruptSession()
advanceUntilIdle()
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, mapOf("session_id" to sessionId), any()) }
verify {
HermesWsClient.send(
WsMethods.SESSION_INTERRUPT,
match { it["session_id"] == JsonPrimitive(sessionId) && it.size == 1 },
any(),
)
}
}
@Test
@@ -6592,9 +6707,9 @@ class ChatViewModelTest {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals(sessionId, params["session_id"])
assertEquals("once", params["choice"])
assertEquals("req-42", params["request_id"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
assertEquals(JsonPrimitive("once"), params["choice"])
assertEquals(JsonPrimitive("req-42"), params["request_id"])
},
any(),
)
@@ -6626,8 +6741,8 @@ class ChatViewModelTest {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("session", params["choice"])
assertEquals("req-7", params["request_id"])
assertEquals(JsonPrimitive("session"), params["choice"])
assertEquals(JsonPrimitive("req-7"), params["request_id"])
},
any(),
)
@@ -6657,7 +6772,7 @@ class ChatViewModelTest {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("deny", params["choice"])
assertEquals(JsonPrimitive("deny"), params["choice"])
assertFalse(params.containsKey("request_id"))
},
any(),
@@ -6690,7 +6805,7 @@ class ChatViewModelTest {
HermesWsClient.send(
WsMethods.APPROVAL_PENDING,
withArg { params ->
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
},
any(),
)
@@ -6736,8 +6851,8 @@ class ChatViewModelTest {
HermesWsClient.send(
WsMethods.APPROVAL_RECEIVED,
withArg { params ->
assertEquals("runtime-sid-999", params["session_id"])
assertEquals("req-runtime-test", params["request_id"])
assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"])
assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"])
},
any(),
)
@@ -6750,16 +6865,16 @@ class ChatViewModelTest {
HermesWsClient.send(
WsMethods.APPROVAL_RESPOND,
withArg { params ->
assertEquals("runtime-sid-999", params["session_id"])
assertEquals("once", params["choice"])
assertEquals("req-runtime-test", params["request_id"])
assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"])
assertEquals(JsonPrimitive("once"), params["choice"])
assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"])
},
any(),
)
HermesWsClient.send(
WsMethods.APPROVAL_PENDING,
withArg { params ->
assertEquals("runtime-sid-999", params["session_id"])
assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"])
},
any(),
)
@@ -7221,7 +7336,7 @@ class ChatViewModelTest {
verify { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any()) }
assertEquals(
"session_id must be forwarded to image.attach_bytes",
sessionId,
JsonPrimitive(sessionId),
paramsSlot.captured["session_id"],
)
assertTrue("encoded attachment cache must be cleaned", attachmentCacheDir.listFiles().isNullOrEmpty())
@@ -9470,7 +9585,7 @@ class ChatViewModelTest {
any(),
)
}
assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"])
assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"])
}
@Test
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.MessageReaction
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
@@ -9,6 +10,43 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class ChatWsEventReducerTest {
@Test
fun interruptedIdentitySurvivesDoneButNotANewStart() {
val partial = ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "old reply")
val state = ChatUiState(currentSessionId = "session-1", messages = listOf(partial))
val interrupted = StreamingState(interruptedMessage = partial)
val done =
ChatWsEventReducer.reduce(
state,
interrupted,
WsEvent.MessageDone("session-1"),
"session-1",
)
assertEquals(partial, done.streamingState.interruptedMessage)
val started =
ChatWsEventReducer.reduce(
done.state,
done.streamingState,
WsEvent.MessageStart("session-1"),
"session-1",
)
assertNull(started.streamingState.interruptedMessage)
val complete =
ChatWsEventReducer.reduce(
started.state,
started.streamingState,
WsEvent.MessageComplete("new reply", "session-1"),
"session-1",
)
assertEquals(
"old reply",
complete.state.messages
.first()
.content,
)
assertEquals(2, complete.state.messages.count { it.role == MessageRole.ASSISTANT })
}
@Test
fun testMessageComplete_clearsResolvedClarifyRequest() {
val state =
@@ -1605,4 +1643,60 @@ class ChatWsEventReducerTest {
assertEquals("idle", result.state.compressionStatus)
assertEquals(initialMessages, result.state.messages)
}
@Test
fun messageReactionPaintsOnlyTheRowWithTheMatchingServerId() {
val target = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L)
val other = ChatMessage(id = "b", role = MessageRole.ASSISTANT, content = "yo", serverRowId = 8L)
val state = ChatUiState(currentSessionId = "s", messages = listOf(target, other))
val reactions = listOf(MessageReaction("\u2764\uFE0F", "agent"))
val result =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.MessageReactionUpdated(7L, reactions, "user", "s"),
"s",
)
assertEquals(reactions, result.state.messages[0].reactions)
assertTrue(
result.state.messages[1]
.reactions
.isEmpty(),
)
val retracted =
ChatWsEventReducer.reduce(
result.state,
result.streamingState,
WsEvent.MessageReactionUpdated(7L, emptyList(), "user", "s"),
"s",
)
assertTrue(
retracted.state.messages[0]
.reactions
.isEmpty(),
)
}
@Test
fun messageReactionForUnknownRowOrOtherSessionIsIgnored() {
val msg = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L)
val state = ChatUiState(currentSessionId = "s", messages = listOf(msg))
val reactions = listOf(MessageReaction("\uD83D\uDC4D", "agent"))
val unknownRow =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.MessageReactionUpdated(99L, reactions, "user", "s"),
"s",
)
assertEquals(state, unknownRow.state)
val otherSession =
ChatWsEventReducer.reduce(
state,
StreamingState(),
WsEvent.MessageReactionUpdated(7L, reactions, "user", "zzz"),
"s",
)
assertEquals(state, otherSession.state)
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import com.m57.hermescontrol.theme.GlyphWhite
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.resolveColorScheme
import org.junit.Assert.assertTrue
@@ -66,6 +67,20 @@ class ComposerPaletteTest {
}
}
@Test
fun actionButtonFollowsThePresetPrimaryWhenItIsLegible() {
forEveryPresetMode { mode, p, _ ->
val scheme = resolveColorScheme(ThemePreset.valueOf(mode.substringBefore(" ")), mode.endsWith("true"))
if (contrast(scheme.primary, p.card) >= 3f) {
assertTrue("$mode action must be primary", p.action == scheme.primary)
assertTrue(
"$mode glyph must be white or onPrimary",
p.onAction == scheme.onPrimary || p.onAction == GlyphWhite,
)
}
}
}
@Test
fun controlsAndCardEdgeStayVisibleInEveryPresetMode() {
forEveryPresetMode { mode, p, background ->
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
import com.m57.hermescontrol.ui.chat.fullbleed.searchMatchToLazyIndex
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -24,6 +25,37 @@ class FullBleedItemKeysTest {
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
}
@Test
fun searchTargetsResolveHoistedReasoningAndToolRows() {
val messages =
listOf(
ChatMessage("u", MessageRole.USER, "question"),
ChatMessage("t", MessageRole.TOOL, "output", toolName = "terminal"),
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
)
val turns = groupIntoTurns(messages)
assertEquals(1, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0, SearchTarget.REASONING)))
assertEquals(2, searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.TOOL)))
assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0)))
}
@Test
fun secondaryReasoningResolvesToItsProseRow() {
val messages =
listOf(
ChatMessage("a1", MessageRole.ASSISTANT, "one", reasoningText = "first"),
ChatMessage("a2", MessageRole.ASSISTANT, "two", reasoningText = "second"),
)
val turns = groupIntoTurns(messages)
val keys = fullBleedItemKeys(turns)
assertEquals(
keys.indexOf("prose-a2"),
searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.REASONING)),
)
}
@Test
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")
@@ -1,91 +0,0 @@
package com.m57.hermescontrol.ui.chat
import android.content.Context
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Base64
@OptIn(ExperimentalCoroutinesApi::class)
class ImageBytesResolverTest {
private val context = mockk<Context>(relaxed = true)
@Before
fun setUp() {
// resolve() runs on Dispatchers.IO; bind the main dispatcher for tests.
Dispatchers.setMain(StandardTestDispatcher())
}
@Test
fun `extensionForMime maps known types`() {
assertEquals("png", ImageBytesResolver.extensionForMime("image/png"))
assertEquals("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary"))
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg"))
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG"))
assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif"))
assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp"))
assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic"))
assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml"))
assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown"))
}
@Test
fun `data URL base64 decodes to bytes with mime and extension`() =
runTest {
val raw = "hello-image".toByteArray()
val b64 = Base64.getEncoder().encodeToString(raw)
val model = "data:image/png;base64,$b64"
val result = ImageBytesResolver.resolve(context, model, "image/*")
assertTrue(result is ImageBytesResolver.Result.Bytes)
result as ImageBytesResolver.Result.Bytes
assertArrayEquals(raw, result.bytes)
assertEquals("image/png", result.mimeType)
assertEquals("png", result.extension)
}
@Test
fun `data URL falls back to fallbackMime when meta omitted`() =
runTest {
val raw = byteArrayOf(1, 2, 3, 4)
val b64 = Base64.getEncoder().encodeToString(raw)
val model = "data:;base64,$b64"
val result = ImageBytesResolver.resolve(context, model, "image/webp")
assertTrue(result is ImageBytesResolver.Result.Bytes)
result as ImageBytesResolver.Result.Bytes
assertEquals("image/webp", result.mimeType)
assertEquals("webp", result.extension)
}
@Test
fun `malformed data URL (no comma) returns Error`() =
runTest {
val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*")
assertTrue(result is ImageBytesResolver.Result.Error)
}
@Test
fun `non-base64 data URL returns Error`() =
runTest {
val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*")
assertTrue(result is ImageBytesResolver.Result.Error)
}
@Test
fun `unsupported model source returns Error`() =
runTest {
val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*")
assertTrue(result is ImageBytesResolver.Result.Error)
}
}
@@ -0,0 +1,76 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.StatusBlue
import com.m57.hermescontrol.theme.StatusBlueContainer
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusGreenContainer
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test
import kotlin.system.measureNanoTime
/** Diagnostic only (opt-in via -PincludeBenchmarks): per-update cost while a list streams from 1 to 1000 bullets. */
class MarkdownBulletRunBenchmark {
private val highlights =
searchHighlightColors(
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = Color.White,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = Color.White,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = Color.White,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = Color.White,
),
)
private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md))
private fun bullets(n: Int) = (1..n).joinToString("\n") { "- item $it" }
private fun render(run: BulletRun): AnnotatedString =
buildBulletRunText(run, Density(1f), Color.Black, "", false, Color.Blue, highlights) { (it.length * 7).sp }
@Test
fun benchmarkStreamingGrowth() {
val sizes = listOf(100, 250, 500, 750, 1000)
val sb = StringBuilder()
var done = 0
val results = mutableListOf<String>()
// warm the JIT
repeat(30) { coalesce(bullets(1000)) }
for (target in sizes) {
while (done < target) {
sb.append("- item ${++done}\n")
}
val md = sb.toString()
val n = 50
val parse = measureNanoTime { repeat(n) { parseBlocks(md) } } / n / 1e6
val full =
measureNanoTime {
repeat(n) {
(coalesce(md).firstOrNull { it is BulletRun } as? BulletRun)?.let { render(it) }
}
} / n / 1e6
results += "$target bullets: parse=${"%.2f".format(parse)}ms parse+coalesce+build=${"%.2f".format(full)}ms"
}
results.forEach { println("BENCHMARK_RESULT: stream $it") }
}
}
@@ -0,0 +1,240 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.StatusBlue
import com.m57.hermescontrol.theme.StatusBlueContainer
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusGreenContainer
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
import com.m57.hermescontrol.ui.chat.markdown.MIN_BULLET_RUN
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
private val HIGHLIGHTS =
searchHighlightColors(
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = Color.White,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = Color.White,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = Color.White,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = Color.White,
),
)
private fun bullets(
n: Int,
text: (Int) -> String = { "item $it" },
) = (1..n).joinToString("\n") { "- ${text(it)}" }
private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md))
/** Stand-in for TextMeasurer: prefix width grows with font size, like the real bullet glyph. */
private fun fakePrefixWidth(fontSizeSp: Float): (String) -> TextUnit =
{ prefix -> (prefix.length * fontSizeSp * 0.5f).sp }
private fun render(
run: BulletRun,
query: String = "",
density: Density = Density(1f),
fontSizeSp: Float = 14f,
) = buildBulletRunText(run, density, Color.Black, query, false, Color.Blue, HIGHLIGHTS, fakePrefixWidth(fontSizeSp))
class MarkdownBulletRunTest {
@Test
fun longPlainRunCoalescesIntoOneBlock() {
val out = coalesce(bullets(1000))
assertEquals(1, out.size)
assertEquals(1000, (out.single() as BulletRun).items.size)
}
@Test
fun shortRunStaysUncoalesced() {
val out = coalesce(bullets(MIN_BULLET_RUN - 1))
assertEquals(MIN_BULLET_RUN - 1, out.size)
assertTrue(out.all { it is MdBlock.Bullet })
}
@Test
fun runAtThresholdCoalesces() {
assertTrue(coalesce(bullets(MIN_BULLET_RUN)).single() is BulletRun)
}
@Test
fun rtlBulletBreaksTheRunAndKeepsOldRenderer() {
val md = bullets(40) { if (it == 20) "مرحبا بالعالم" else "item $it" }
val out = coalesce(md)
assertEquals(3, out.size)
assertEquals(19, (out[0] as BulletRun).items.size)
assertTrue(out[1] is MdBlock.Bullet)
assertEquals(20, (out[2] as BulletRun).items.size)
}
@Test
fun fullyRtlListNeverCoalesces() {
val out = coalesce(bullets(40) { "مرحبا $it" })
assertTrue(out.none { it is BulletRun })
}
@Test
fun inlineMathBreaksTheRun() {
val out = coalesce(bullets(40) { if (it == 20) "energy \$E=mc^2\$" else "item $it" })
assertTrue(out[1] is MdBlock.Bullet)
assertEquals(3, out.size)
}
@Test
fun nestedBulletsStayInTheRunWithLevelGlyphAndIndent() {
val md = bullets(20) + "\n - child\n - grandchild\n" + bullets(20)
val run = coalesce(md).single() as BulletRun
assertEquals(42, run.items.size)
assertEquals(
listOf(0, 1, 2, 0),
listOf(run.items[19], run.items[20], run.items[21], run.items[22]).map { it.level },
)
val text = render(run)
val lines = text.text.split("\n")
assertTrue(lines[20].startsWith("\u25E6"))
assertTrue(lines[21].startsWith("\u25AA"))
val indents =
text.paragraphStyles.map {
it.item.textIndent!!
.firstLine.value
}
assertEquals(listOf(0f, 16f, 32f, 0f), listOf(indents[19], indents[20], indents[21], indents[22]))
}
@Test
fun bulletWithNestedSourceFallsBack() {
val md = bullets(20) + "\n- parent\n ```\n code\n ```\n" + bullets(20)
val out = coalesce(md)
assertTrue(out.any { it is MdBlock.Bullet && it.nestedSource.isNotEmpty() })
assertTrue(out.filterIsInstance<BulletRun>().flatMap { it.items }.all { it.nestedSource.isEmpty() })
}
@Test
fun taskAndOrderedItemsNeverJoinARun() {
val md = bullets(20) + "\n- [ ] todo\n1. one\n2. two\n" + bullets(20)
val out = coalesce(md)
assertEquals(2, out.count { it is BulletRun })
assertTrue(out.any { it is MdBlock.Task })
assertTrue(out.any { it is MdBlock.Ordered })
}
@Test
fun surroundingBlocksAreUntouchedAndOrdered() {
val out = coalesce("# Title\n\n" + bullets(30) + "\n\nTail paragraph")
assertTrue(out.first() is MdBlock.Heading)
assertTrue(out[1] is BulletRun)
assertTrue(out.last() is MdBlock.Paragraph)
}
@Test
fun mergedTextKeepsEveryLineWithGlyphs() {
val run = coalesce(bullets(20)).single() as BulletRun
val lines = render(run).text.split("\n")
assertEquals(20, lines.size)
assertTrue(lines.all { it.startsWith("\u2022") })
assertTrue(lines.last().endsWith("item 20"))
}
@Test
fun linksSurviveMerging() {
val run =
coalesce(bullets(20) { if (it == 5) "see [docs](https://example.com/a) now" else "item $it" })
.single() as BulletRun
val text = render(run)
val links = text.getLinkAnnotations(0, text.length)
assertEquals(1, links.size)
assertEquals("https://example.com/a", (links.single().item as LinkAnnotation.Url).url)
assertEquals("docs", text.text.substring(links.single().start, links.single().end))
}
@Test
fun bareUrlsStayLinks() {
val run = coalesce(bullets(20) { if (it == 3) "https://example.com/x" else "item $it" }).single() as BulletRun
val text = render(run)
assertEquals(1, text.getLinkAnnotations(0, text.length).size)
}
@Test
fun searchHighlightLandsOnTheMatchOffsets() {
val run = coalesce(bullets(20)).single() as BulletRun
val text = render(run, query = "item 7")
val spans = text.spanStyles.filter { it.item.background != Color.Unspecified }
assertTrue(spans.isNotEmpty())
spans.forEach { assertEquals("item 7", text.text.substring(it.start, it.end)) }
}
@Test
fun noHighlightWithoutQuery() {
val run = coalesce(bullets(20)).single() as BulletRun
assertTrue(render(run).spanStyles.none { it.item.background != Color.Unspecified })
}
@Test
fun levelOffsetIsPhysicalDpAcrossFontScales() {
val run = coalesce(bullets(20) + "\n - child\n").single() as BulletRun
val normal = render(run, density = Density(2f, fontScale = 1f))
val big = render(run, density = Density(2f, fontScale = 2f))
val n =
normal.paragraphStyles[20]
.item.textIndent!!
.firstLine.value
val b =
big.paragraphStyles[20]
.item.textIndent!!
.firstLine.value
// 16dp at density 2 = 32px; sp value shrinks by fontScale so physical size stays 16dp.
assertEquals(n, b * 2f, 0.001f)
}
@Test
fun hangingIndentIncludesTheFontRelativePrefix() {
val run = coalesce(bullets(20)).single() as BulletRun
val small =
render(run, fontSizeSp = 14f)
.paragraphStyles
.first()
.item.textIndent!!
val large =
render(run, fontSizeSp = 28f)
.paragraphStyles
.first()
.item.textIndent!!
// Wrapped lines must sit right of the first line by the prefix width, which grows with the font.
assertTrue(small.restLine.value > small.firstLine.value)
assertEquals(
(small.restLine.value - small.firstLine.value) * 2f,
large.restLine.value - large.firstLine.value,
0.001f,
)
}
@Test
fun oneParagraphStylePerBullet() {
val run = coalesce(bullets(50)).single() as BulletRun
assertEquals(50, render(run).paragraphStyles.size)
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test
import kotlin.system.measureNanoTime
import kotlin.system.measureTimeMillis
class MarkdownTextBenchmark {
@@ -47,4 +48,19 @@ class MarkdownTextBenchmark {
println("BENCHMARK_RESULT: parseBlocks 10000 iterations took ${time}ms")
}
@Test
fun benchmarkParseBlocksThousandBullets() {
val md = (1..1000).joinToString("\n") { "- $it" }
// Cold: first parse in this JVM, before any warmup.
val cold = measureNanoTime { parseBlocks(md) }
repeat(50) { parseBlocks(md) }
val iterations = 200
val warmNanos = measureNanoTime { repeat(iterations) { parseBlocks(md) } }
println("BENCHMARK_RESULT: parseBlocks 1000 bullets cold=${cold / 1_000_000.0}ms")
println("BENCHMARK_RESULT: parseBlocks 1000 bullets warm avg=${warmNanos / iterations / 1_000_000.0}ms")
}
}
@@ -459,6 +459,28 @@ class MarkdownTextFeatureTest {
assertEquals(0, o16.level)
}
@Test
fun quotedBlocksRetainNestedHeadingsListsAndFences() {
val source = "> # Heading\n> - item\n> ```kotlin\n> val x = 1\n> ```"
val quote = parseBlocks(source).single() as MdBlock.Quote
val nested = parseBlocks(quote.text)
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Heading" })
val bullet = nested.filterIsInstance<MdBlock.Bullet>().single()
assertEquals("item", bullet.text)
assertEquals("val x = 1", (parseBlocks(bullet.nestedSource).single() as MdBlock.Code).code)
}
@Test
fun listItemsRetainFencedCodeAndHeadingsAsNestedBlocks() {
val source = "- intro\n ```kotlin\n val x = 1\n ```\n # Inside\n- next"
val bullets = parseBlocks(source).filterIsInstance<MdBlock.Bullet>()
assertEquals(listOf("intro", "next"), bullets.map { it.text })
val nested = parseBlocks(bullets.first().nestedSource)
assertTrue(nested.any { it is MdBlock.Code && it.code == "val x = 1" })
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Inside" })
assertEquals("", bullets.last().nestedSource)
}
// 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES
@Test
fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() {
@@ -0,0 +1,62 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.Attachment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class MediaSegmentsTest {
private fun att(
name: String,
offset: Int?,
) = Attachment(uri = "u/$name", name = name, mimeType = "image/png", contentOffset = offset)
private fun kinds(segments: List<MessageSegment>) =
segments.map {
when (it) {
is MessageSegment.Text -> "T:${it.text}"
is MessageSegment.Media -> "M:${it.attachment.name}"
}
}
@Test
fun extractRecordsOffsetsIntoStrippedText() {
val text = "First: MEDIA:/a/1.png\nthen second: MEDIA:/a/2.png\nend"
val stripped = HostMediaExtractor.strip(text)
val items = HostMediaExtractor.extract(text)
assertEquals(2, items.size)
assertTrue(items[0].offset <= items[1].offset)
assertTrue(items[1].offset <= stripped.length)
}
@Test
fun mediaLandsBetweenTheParagraphsItSatBetween() {
val text = "Chart one:\nMEDIA:/a/1.png\nChart two:\nMEDIA:/a/2.png\nDone."
val stripped = HostMediaExtractor.strip(text)
val items = HostMediaExtractor.extract(text)
val atts = items.map { att(it.path.substringAfterLast('/'), it.offset) }
val out = kinds(splitByMedia(stripped, atts))
assertEquals(listOf("T:Chart one:", "M:1.png", "T:Chart two:", "M:2.png", "T:Done."), out)
}
@Test
fun noOffsetAppendsAfterText() {
val out = kinds(splitByMedia("Hello", listOf(att("x.png", null))))
assertEquals(listOf("T:Hello", "M:x.png"), out)
}
@Test
fun mediaOnlyMessageHasNoBlankText() {
val out = kinds(splitByMedia("", listOf(att("x.png", 0))))
assertEquals(listOf("M:x.png"), out)
}
@Test
fun mediaDoesNotSplitAFencedCodeBlock() {
val content = "Look:\n```\ncode line\nmore\n```\nafter"
val out = kinds(splitByMedia(content, listOf(att("x.png", content.indexOf("code line")))))
assertEquals(2, out.count { it.startsWith("T:") })
assertTrue(out[0].contains("more") && out[0].endsWith("```"))
assertEquals("M:x.png", out[1])
}
}
@@ -30,6 +30,30 @@ class MediaTypesTest {
assertEquals("application/octet-stream", mediaMimeForPath("/tmp/x.weird"))
}
@Test
fun `extension for mime uses one table plus aliases`() {
// Table entries, including params and case.
assertEquals("png", extensionForMime("image/png"))
assertEquals("png", extensionForMime(" IMAGE/PNG; charset=binary"))
assertEquals("ogg", extensionForMime("audio/ogg; codecs=opus"))
assertEquals("mkv", extensionForMime("video/x-matroska"))
assertEquals("pdf", extensionForMime("application/pdf"))
// Aliases win over first-table-match.
assertEquals("jpg", extensionForMime("image/jpeg"))
assertEquals("jpg", extensionForMime("image/jpg"))
assertEquals("heic", extensionForMime("image/heif"))
assertEquals("mp3", extensionForMime("audio/mp3"))
assertEquals("m4a", extensionForMime("audio/x-m4a"))
assertEquals("wav", extensionForMime("audio/wave"))
assertEquals("flac", extensionForMime("application/x-flac"))
assertEquals("ogg", extensionForMime("application/ogg"))
// Family fallbacks.
assertEquals("mp4", extensionForMime("video/unknown"))
assertEquals("mp3", extensionForMime("audio/unknown"))
assertEquals("img", extensionForMime("image/unknown"))
assertEquals("bin", extensionForMime("application/unknown"))
}
@Test
fun `name extraction handles urls and paths`() {
assertEquals("report.pdf", mediaNameFromPath("https://gw/api/files/download?path=%2Ftmp%2Freport.pdf"))
@@ -169,11 +169,29 @@ class ReadNotificationReviewRegressionTest {
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
)
for (latest in listOf(true, false)) {
val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context)
val mapped =
mapServerMessages(
"session",
rows,
10,
latest,
listOf(live),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertEquals(listOf("comp", null), mapped.map { it.completionId })
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId)
assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId)
// Target row absent from this page: the reserved completion must fail closed, not rebind by text.
val targetRowAbsent =
mapServerMessages(
"session",
rows.takeLast(1),
11,
latest,
listOf(live),
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertNull(targetRowAbsent.single().completionId)
}
}
@@ -214,6 +232,7 @@ class ReadNotificationReviewRegressionTest {
0,
true,
current,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(),
)
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single()
assertEquals("ws", merged.id)
@@ -256,7 +275,7 @@ class ReadNotificationReviewRegressionTest {
latestPaging = true,
liveMessages = current,
isPagingOlder = false,
context = context,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true)
@@ -685,7 +704,15 @@ class ReadNotificationReviewRegressionTest {
)
val mapped =
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
mapServerMessages(
"session",
history,
0,
true,
emptyList(),
isPagingOlder = false,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertEquals("Older message must have null completionId", null, mapped[0].completionId)
assertEquals("Newest message must receive target completionId", "comp-newest-done", mapped[1].completionId)
@@ -747,7 +774,15 @@ class ReadNotificationReviewRegressionTest {
)
val mapped =
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
mapServerMessages(
"session",
history,
0,
true,
emptyList(),
isPagingOlder = false,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertEquals("comp-long-2", mapped[0].completionId)
assertEquals(null, mapped[1].completionId)
@@ -796,7 +831,15 @@ class ReadNotificationReviewRegressionTest {
)
val mapped =
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
mapServerMessages(
"session",
history,
0,
true,
emptyList(),
isPagingOlder = false,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
val row = mapped.single()
assertEquals("comp-media-cold", row.completionId)
assertEquals("Here is the image", row.content)
@@ -839,7 +882,15 @@ class ReadNotificationReviewRegressionTest {
)
val mapped =
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
mapServerMessages(
"session",
history,
0,
true,
emptyList(),
isPagingOlder = false,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
val row = mapped.single()
assertEquals("comp-media-only-cold", row.completionId)
assertEquals("", row.content)
@@ -887,7 +938,15 @@ class ReadNotificationReviewRegressionTest {
)
val mapped =
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
mapServerMessages(
"session",
history,
0,
true,
emptyList(),
isPagingOlder = false,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertEquals("comp-media-dup-2", mapped[0].completionId)
assertEquals(null, mapped[1].completionId)
@@ -955,7 +1014,15 @@ class ReadNotificationReviewRegressionTest {
}
val mapped =
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
mapServerMessages(
"session",
history,
0,
true,
emptyList(),
isPagingOlder = false,
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
)
assertEquals(listOf(null, null, "comp-turn"), mapped.map { it.completionId })
assertFalse(
@@ -12,6 +12,9 @@ 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.data.ws.contract.ConfigGetParams
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
@@ -21,6 +24,7 @@ import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -32,8 +36,10 @@ 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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -213,9 +219,9 @@ class SlashCommandDispatchRpcTest {
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals("help", params["name"])
assertEquals("", params["arg"])
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive("help"), params["name"])
assertEquals(JsonPrimitive(""), params["arg"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
@@ -271,35 +277,27 @@ class SlashCommandDispatchRpcTest {
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
val setCalls = mutableListOf<ConfigSetParams>()
coEvery {
HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_SET) {
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
}
}
vm.sendMessage("/reasoning high --global")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals("high", params["value"])
assertEquals(sessionId, params["session_id"])
assertEquals("global", params["scope"])
assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size)
val params = setCalls.single()
assertEquals("reasoning", params.key)
assertEquals("high", params.value)
assertEquals(sessionId, params.sessionId)
assertEquals("global", params.scope)
assertEquals("high", vm.uiState.value.reasoningLevel)
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
}
@Test
@@ -307,33 +305,26 @@ class SlashCommandDispatchRpcTest {
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
val setCalls = mutableListOf<ConfigSetParams>()
coEvery {
HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_SET) {
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
kotlinx.serialization.json.buildJsonObject {
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
}
}
vm.sendMessage("/reasoning show")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals("show", params["value"])
assertEquals(sessionId, params["session_id"])
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size)
val params = setCalls.single()
assertEquals("reasoning", params.key)
assertEquals("show", params.value)
assertEquals(sessionId, params.sessionId)
assertNull(params.scope)
verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
assertEquals(
"reasoning: show",
vm.uiState.value.messages
@@ -347,32 +338,24 @@ class SlashCommandDispatchRpcTest {
runTest {
val (vm, sessionId) = createViewModelWithSession()
val methodCalls = mutableListOf<String>()
val paramsCalls = mutableListOf<Map<String, Any>>()
every {
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
val getCalls = mutableListOf<ConfigGetParams>()
coEvery {
HermesWsClient.call(RpcMethods.CONFIG_GET, capture(getCalls), any(), any())
} answers {
val result =
if (arg<String>(0) == WsMethods.CONFIG_GET) {
kotlinx.serialization.json.buildJsonObject {
put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
}
} else {
kotlinx.serialization.json.JsonNull
}
CompletableDeferred<Any?>(result)
kotlinx.serialization.json.buildJsonObject {
put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
}
}
vm.sendMessage("/reasoning")
advanceUntilIdle()
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET)
assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0)
val params = paramsCalls[configIndex]
assertEquals("reasoning", params["key"])
assertEquals(sessionId, params["session_id"])
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
assertEquals("expected exactly one config.get, got $getCalls", 1, getCalls.size)
val params = getCalls.single()
assertEquals("reasoning", params.key)
assertEquals(sessionId, params.sessionId)
verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
assertEquals(
"reasoning: ultra · display hide",
vm.uiState.value.messages
@@ -500,8 +483,8 @@ class SlashCommandDispatchRpcTest {
assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0)
assertTrue("dispatch must precede fallback", dispatchIndex < execIndex)
val params = paramsCalls[execIndex]
assertEquals("/status", params["command"])
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive("/status"), params["command"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
// And the slash.exec output must be surfaced to the user.
val last =
@@ -647,9 +630,9 @@ class SlashCommandDispatchRpcTest {
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals("init", params["name"])
assertEquals("", params["arg"])
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive("init"), params["name"])
assertEquals(JsonPrimitive(""), params["arg"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
@@ -672,9 +655,9 @@ class SlashCommandDispatchRpcTest {
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals("init", params["name"])
assertEquals("extra context here", params["arg"])
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive("init"), params["name"])
assertEquals(JsonPrimitive("extra context here"), params["arg"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
@@ -700,9 +683,9 @@ class SlashCommandDispatchRpcTest {
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals("focus", params["name"])
assertEquals("", params["arg"])
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive("focus"), params["name"])
assertEquals(JsonPrimitive(""), params["arg"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
@@ -725,9 +708,9 @@ class SlashCommandDispatchRpcTest {
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
val params = paramsCalls[dispatchIndex]
assertEquals("focus", params["name"])
assertEquals("on", params["arg"])
assertEquals(sessionId, params["session_id"])
assertEquals(JsonPrimitive("focus"), params["name"])
assertEquals(JsonPrimitive("on"), params["arg"])
assertEquals(JsonPrimitive(sessionId), params["session_id"])
}
@Test
@@ -20,8 +20,8 @@ class SlashCommandDispatcherTest {
private val dispatcher = SlashCommandDispatcher()
@Test
fun `stop routes to Interrupt`() {
assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop"))
fun `stop routes to Stop`() {
assertEquals(SlashResult.Stop, dispatcher.dispatch("/stop"))
}
@Test
@@ -0,0 +1,56 @@
package com.m57.hermescontrol.ui.chat
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class StreamingBufferTest {
@Test
fun channelsThrottleIndependentlyAndFlushCumulativeText() =
runTest {
var now = 100L
val tokens = mutableListOf<String>()
val reasoning = mutableListOf<String>()
val tokenBuffer = StreamingBuffer(this, 33L, { now }, tokens::add)
val reasoningBuffer = StreamingBuffer(this, 33L, { now }, reasoning::add)
tokenBuffer.append("A", flushImmediately = false)
tokenBuffer.append("B", flushImmediately = false)
reasoningBuffer.append("R", flushImmediately = false)
assertEquals(listOf("A"), tokens)
assertEquals(listOf("R"), reasoning)
now += 34
testScheduler.advanceTimeBy(34L)
assertEquals(listOf("A", "AB"), tokens)
assertEquals(listOf("R"), reasoning)
}
@Test
fun transitionFlushCancelsTrailingJobWithoutDroppingTheNextDelta() =
runTest {
val output = mutableListOf<String>()
val buffer = StreamingBuffer(this, 33L, { 100L }, output::add)
buffer.append("A", flushImmediately = false)
buffer.append("B", flushImmediately = false)
buffer.flushPending()
testScheduler.advanceTimeBy(34L)
assertEquals(listOf("A", "AB"), output)
buffer.append("C", flushImmediately = true)
assertEquals(listOf("A", "AB", "ABC"), output)
}
@Test
fun clearCancelsTrailingJobAndStartsFresh() =
runTest {
val output = mutableListOf<String>()
val buffer = StreamingBuffer(this, 33L, { 100L }, output::add)
buffer.append("Old", flushImmediately = false)
buffer.append(" tail", flushImmediately = false)
buffer.clear()
testScheduler.advanceTimeBy(34L)
buffer.append("Fresh", flushImmediately = false)
assertEquals(listOf("Old", "Fresh"), output)
}
}
@@ -0,0 +1,248 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
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.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatScrollControllerTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun mockLazyListStateAtBottom(): LazyListState {
val listState = mockk<LazyListState>(relaxed = true)
val layoutInfo = mockk<LazyListLayoutInfo>(relaxed = true)
val itemInfo = mockk<LazyListItemInfo>(relaxed = true)
every { layoutInfo.totalItemsCount } returns 5
every { layoutInfo.viewportEndOffset } returns 1000
every { layoutInfo.afterContentPadding } returns 0
every { itemInfo.index } returns 4
every { itemInfo.offset } returns 800
every { itemInfo.size } returns 100
every { layoutInfo.visibleItemsInfo } returns listOf(itemInfo)
every { listState.layoutInfo } returns layoutInfo
return listState
}
@Test
fun `initial state has isFollowingBottom true and pendingCount 0`() {
val listState = mockk<LazyListState>(relaxed = true)
val scope = TestScope(testDispatcher)
val controller = ChatScrollController(listState, scope)
assertTrue(controller.isFollowingBottom)
assertEquals(0, controller.pendingCount)
}
@Test
fun `new tail while following keeps pendingCount 0 and requests scroll`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
val controller = ChatScrollController(listState, this)
controller.onTailChanged(tailKey = "msg-1", messageCount = 1)
advanceUntilIdle()
assertTrue(controller.isFollowingBottom)
assertEquals(0, controller.pendingCount)
coVerify(atLeast = 1) { listState.scrollToItem(4, any()) }
}
@Test
fun `pauseFollowing followed by new messages increments pendingCount`() {
val listState = mockk<LazyListState>(relaxed = true)
val scope = TestScope(testDispatcher)
val controller = ChatScrollController(listState, scope)
controller.pauseFollowing()
assertFalse(controller.isFollowingBottom)
controller.onTailChanged(tailKey = "msg-1", messageCount = 1)
assertEquals(1, controller.pendingCount)
controller.onTailChanged(tailKey = "msg-2", messageCount = 3)
assertEquals(3, controller.pendingCount)
assertFalse(controller.isFollowingBottom)
}
@Test
fun `resumeFollowing clears pendingCount and restores isFollowingBottom`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
val controller = ChatScrollController(listState, this)
controller.pauseFollowing()
controller.onTailChanged(tailKey = "msg-1", messageCount = 2)
assertEquals(2, controller.pendingCount)
assertFalse(controller.isFollowingBottom)
controller.resumeFollowing()
advanceUntilIdle()
assertTrue(controller.isFollowingBottom)
assertEquals(0, controller.pendingCount)
coVerify(atLeast = 1) { listState.animateScrollToItem(4, any()) }
}
@Test
fun `jumpToBottom clears pendingCount and restores isFollowingBottom`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
val controller = ChatScrollController(listState, this)
controller.pauseFollowing()
controller.onTailChanged(tailKey = "msg-1", messageCount = 4)
assertEquals(4, controller.pendingCount)
assertFalse(controller.isFollowingBottom)
controller.jumpToBottom(animated = false)
advanceUntilIdle()
assertTrue(controller.isFollowingBottom)
assertEquals(0, controller.pendingCount)
coVerify(atLeast = 1) { listState.scrollToItem(4, any()) }
}
@Test
fun `tail key unchanged causes no change to pendingCount or scroll`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
val controller = ChatScrollController(listState, this)
controller.pauseFollowing()
controller.onTailChanged(tailKey = "same-key", messageCount = 1)
assertEquals(1, controller.pendingCount)
// Repeating the same tailKey should be a no-op
controller.onTailChanged(tailKey = "same-key", messageCount = 5)
assertEquals(1, controller.pendingCount)
advanceUntilIdle()
coVerify(exactly = 0) { listState.scrollToItem(any(), any()) }
coVerify(exactly = 0) { listState.animateScrollToItem(any(), any()) }
}
@Test
fun `layout growth does not pause follow but returning to bottom after user gesture resumes`() =
runTest(testDispatcher) {
var atBottom by mutableStateOf(true)
val listState = mockLazyListStateAtBottom()
val layoutInfo = listState.layoutInfo
val itemInfo = layoutInfo.visibleItemsInfo.single()
every { itemInfo.index } answers { if (atBottom) 4 else 3 }
val controller = ChatScrollController(listState, backgroundScope)
controller.observeUserScrollPosition()
runCurrent()
atBottom = false // A new row laid out below the viewport, not a user scroll.
Snapshot.sendApplyNotifications()
runCurrent()
assertTrue(controller.isFollowingBottom)
controller.onUserScrollUp()
assertFalse(controller.isFollowingBottom)
atBottom = true
Snapshot.sendApplyNotifications()
runCurrent()
assertTrue(controller.isFollowingBottom)
}
@Test
fun `user upward gesture cancels in-flight follow and counts new messages`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) }
val controller = ChatScrollController(listState, this)
controller.onTailChanged(tailKey = "first", messageCount = 1)
runCurrent()
controller.onUserScrollUp()
controller.onTailChanged(tailKey = "second", messageCount = 2)
advanceUntilIdle()
assertFalse(controller.isFollowingBottom)
assertEquals(1, controller.pendingCount)
coVerify(exactly = 1) { listState.scrollToItem(4, any()) }
}
@Test
fun `historical jump replaces an in-flight follow and leaves follow paused`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) }
val controller = ChatScrollController(listState, this)
controller.onTailChanged(tailKey = "tail", messageCount = 1)
runCurrent()
controller.jumpToHistoryStart()
advanceUntilIdle()
assertFalse(controller.isFollowingBottom)
coVerify(exactly = 1) { listState.scrollToItem(0, any()) }
}
@Test
fun `rapid tails replace older jobs rather than queueing scrolls`() =
runTest(testDispatcher) {
val listState = mockLazyListStateAtBottom()
val controller = ChatScrollController(listState, this)
controller.onTailChanged(tailKey = "one", messageCount = 1)
controller.onTailChanged(tailKey = "two", messageCount = 2)
advanceUntilIdle()
assertTrue(controller.isFollowingBottom)
coVerify(exactly = 1) { listState.scrollToItem(4, any()) }
}
@Test
fun `showFab returns true only when paused and content is present`() {
val listState = mockk<LazyListState>(relaxed = true)
val scope = TestScope(testDispatcher)
val controller = ChatScrollController(listState, scope)
// Following bottom -> false regardless of contentPresent
assertFalse(controller.showFab(contentPresent = true))
assertFalse(controller.showFab(contentPresent = false))
controller.pauseFollowing()
// Paused -> true only if contentPresent
assertTrue(controller.showFab(contentPresent = true))
assertFalse(controller.showFab(contentPresent = false))
}
}
@@ -0,0 +1,88 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatTimelineState
import com.m57.hermescontrol.ui.chat.ChatUiState
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class TranscriptUiStateTest {
private val live = ChatMessage(id = "live", role = MessageRole.USER, content = "hello")
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
private val clarify = ClarifyUi(text = "choose")
@Test
fun `live state preserves prompts streaming and paging actions`() {
val stream = StreamingState()
val result =
TranscriptUiState.resolve(
chat =
ChatUiState(
messages = listOf(live),
currentSessionId = "session",
isAgentTyping = true,
isLoading = true,
isLoadingOlder = true,
hasOlderMessages = true,
clarifyRequest = clarify,
isCompressing = true,
savingAttachmentPath = "server-path",
),
timeline = ChatTimelineState(),
streaming = stream,
savingAttachmentPath = "local-path",
speakingMessageId = "live",
)
assertEquals(listOf(live), result.messages)
assertSame(stream, result.streamingState)
assertEquals("session", result.pagingSessionId)
assertTrue(result.isAgentTyping)
assertTrue(result.isLoading)
assertTrue(result.isLoadingOlder)
assertTrue(result.hasOlderMessages)
assertSame(clarify, result.clarifyRequest)
assertTrue(result.isCompressing)
assertEquals("local-path", result.savingAttachmentPath)
assertEquals("live", result.speakingMessageId)
}
@Test
fun `historical state isolates live tail prompts loading and compression`() {
val result =
TranscriptUiState.resolve(
chat =
ChatUiState(
messages = listOf(live),
currentSessionId = "session",
isAgentTyping = true,
isLoading = true,
isLoadingOlder = true,
hasOlderMessages = true,
clarifyRequest = clarify,
isCompressing = true,
compressionStatus = "compressing",
),
timeline = ChatTimelineState(historyMessages = listOf(historical), historyAnchorRowId = 42),
streaming = StreamingState(streamingMessage = live),
savingAttachmentPath = null,
speakingMessageId = null,
)
assertEquals(listOf(historical), result.messages)
assertNull(result.streamingState.streamingMessage)
assertEquals("session:history:42", result.pagingSessionId)
assertFalse(result.isAgentTyping)
assertFalse(result.isLoading)
assertFalse(result.isLoadingOlder)
assertFalse(result.hasOlderMessages)
assertNull(result.clarifyRequest)
assertFalse(result.isCompressing)
assertNull(result.compressionStatus)
}
}
@@ -0,0 +1,53 @@
package com.m57.hermescontrol.ui.chat.tool
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Terminal
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class ToolCatalogTest {
@Test
fun `icon and renderer lookups read the same catalog entry`() {
for ((name, def) in ToolCatalog.entries) {
assertSame(name, def.icon, ToolSchemaRegistry.getDisplayConfig(name).icon)
assertSame(name, def.renderer, ToolRendererRegistry.rendererFor(name))
assertEquals(name, ToolSchemaRegistry.getDisplayConfig(name).name)
}
}
@Test
fun `tools that drifted now have a real icon`() {
assertSame(Icons.Filled.Edit, ToolSchemaRegistry.getDisplayConfig("edit_file").icon)
assertSame(Icons.Filled.Keyboard, ToolSchemaRegistry.getDisplayConfig("browser_fill").icon)
for (name in listOf("project_list", "project_create", "project_switch")) {
assertSame(name, Icons.Filled.AccountTree, ToolSchemaRegistry.getDisplayConfig(name).icon)
}
}
@Test
fun `known tools keep their renderers and icons`() {
assertSame(TerminalRenderer, ToolRendererRegistry.rendererFor("terminal"))
assertSame(Icons.Filled.Terminal, ToolSchemaRegistry.getDisplayConfig("terminal").icon)
assertSame(FileEditRenderer, ToolRendererRegistry.rendererFor("edit_file"))
assertSame(BrowserTypeRenderer, ToolRendererRegistry.rendererFor("browser_fill"))
// Icon-only tools still render generically.
assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("clarify"))
}
@Test
fun `unknown tools fall back to build icon and generic renderer`() {
assertSame(Icons.Filled.Build, ToolSchemaRegistry.getDisplayConfig("mystery").icon)
assertEquals("mystery", ToolSchemaRegistry.getDisplayConfig("mystery").name)
assertEquals("tool", ToolSchemaRegistry.getDisplayConfig(null).name)
assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("mystery"))
}
}
@@ -0,0 +1,51 @@
package com.m57.hermescontrol.ui.chat.tool
import com.m57.hermescontrol.ui.chat.parseToolOutput
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Before
import org.junit.Test
class ToolViewCacheTest {
private val payload = """{"name":"terminal","args":{"command":"ls"},"result":{"output":"a","exit_code":0}}"""
@Before
fun reset() = ToolViewCache.clear()
@Test
fun `cached view equals a fresh parse`() {
val cached = ToolViewCache.getOrParse(payload, "terminal", false)
assertEquals(parseToolOutput(payload, "terminal", false), cached)
}
@Test
fun `prewarm makes composition read a hit`() {
ToolViewCache.prewarm(payload, "terminal", false)
val first = ToolViewCache.getOrParse(payload, "terminal", false)
assertSame(first, ToolViewCache.getOrParse(payload, "terminal", false))
assertEquals(1, ToolViewCache.size())
}
@Test
fun `running state and content changes are separate entries`() {
val running = ToolViewCache.getOrParse(payload, "terminal", true)
val done = ToolViewCache.getOrParse(payload, "terminal", false)
assertNotSame(running, done)
assertEquals(2, ToolViewCache.size())
}
@Test
fun `non json content is cached as null`() {
assertNull(ToolViewCache.getOrParse("plain text", "terminal", false))
assertNull(ToolViewCache.getOrParse("plain text", "terminal", false))
assertEquals(1, ToolViewCache.size())
}
@Test
fun `cache is bounded`() {
repeat(600) { i -> ToolViewCache.getOrParse("""{"i":$i}""", "terminal", false) }
assertEquals(512, ToolViewCache.size())
}
}
@@ -10,7 +10,9 @@ 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 com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
@@ -143,7 +145,7 @@ class AccountConnectorsViewModelTest {
runTest(dispatcher) {
advanceUntilIdle()
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok")
coEvery { repo.operationRespond(any()) } returns mapOf("status" to "ok")
coEvery { repo.operationStatus("op-a") } returns
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
vm.connect("drive")
@@ -151,12 +153,11 @@ class AccountConnectorsViewModelTest {
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"),
repo.operationRespond(
ConnectionRespondParams(
owner = ConnectorOwner.account(),
opId = "op-a",
result = ConnectionAnswer(settledBy = "continue"),
),
)
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.model.BulkTaskResult
import com.m57.hermescontrol.data.model.BulkTasksResponse
@@ -25,6 +26,7 @@ import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -43,13 +45,19 @@ class KanbanMutationTest {
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private val preferences = InMemoryKanbanPreferencesStore()
private val createdViewModels = mutableListOf<KanbanViewModel>()
private fun createViewModel(): KanbanViewModel =
KanbanViewModel(
repository = mockRepository,
preferences = preferences,
eventsClientProvider = { mockEventsClient },
endpointProvider = { "http://127.0.0.1:9119" },
)
).also {
// Keep all VM work on the test scheduler; real Dispatchers.IO races resetMain().
it.ioDispatcher = testDispatcher
createdViewModels += it
}
@Before
fun setUp() {
@@ -58,6 +66,9 @@ class KanbanMutationTest {
@After
fun tearDown() {
testDispatcher.scheduler.advanceUntilIdle()
createdViewModels.forEach { it.viewModelScope.cancel() }
createdViewModels.clear()
Dispatchers.resetMain()
}
@@ -3,6 +3,7 @@ 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.McpServerUpdateRequest
import com.m57.hermescontrol.data.model.McpServersResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
@@ -122,10 +123,10 @@ class McpOwnershipTest {
vm.updateEnvValue("value")
vm.addEnvVar(config.name)
dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) }
coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = mapOf("KEY" to "value"))) }
vm.removeEnvVar(config.name, "KEY")
dispatcher.scheduler.advanceUntilIdle()
coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap<String, String>())) }
coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = emptyMap())) }
coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit)
vm.deleteServer(config.name)
dispatcher.scheduler.advanceUntilIdle()
@@ -3,17 +3,23 @@ package com.m57.hermescontrol.ui.process
import com.m57.hermescontrol.data.model.ProcessInfo
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import io.mockk.every
import com.m57.hermescontrol.data.ws.contract.ProcessKillParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
import io.mockk.coEvery
import io.mockk.mockkObject
import io.mockk.slot
import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
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
@@ -42,34 +48,40 @@ class ProcessesViewModelTest {
@Test
fun `process list parses running and exited entries`() {
val raw =
mapOf(
"processes" to
listOf(
mapOf(
"session_id" to "p1",
"command" to "python train.py --epochs 10",
"pid" to 1234,
"status" to "running",
"uptime_seconds" to 95,
"cwd" to "/work",
),
mapOf(
"session_id" to "p2",
"command" to "echo done",
"status" to "exited",
"exit_code" to 0,
),
),
)
val raw: JsonElement =
buildJsonObject {
put(
"processes",
buildJsonArray {
add(
buildJsonObject {
put("session_id", "p1")
put("command", "python train.py --epochs 10")
put("pid", 1234)
put("status", "running")
put("uptime_seconds", 95)
put("cwd", "/work")
},
)
add(
buildJsonObject {
put("session_id", "p2")
put("command", "echo done")
put("status", "exited")
put("exit_code", 0)
},
)
},
)
}
every {
HermesWsClient.request(WsMethods.PROCESS_LIST, any())
} returns CompletableDeferred(raw)
val listParams = slot<SessionIdParams>()
coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, capture(listParams), any(), any()) } returns raw
val vm = ProcessesViewModel()
vm.load()
assertEquals(SessionIdParams("sess-1"), listParams.captured)
val procs = vm.uiState.value.processes
assertEquals(2, procs.size)
@@ -90,26 +102,20 @@ class ProcessesViewModelTest {
@Test
fun `kill issues process kill with session-scoped params then refreshes`() {
var listCalls = 0
val killParamsSlot = slot<Map<String, Any>>()
val killParamsSlot = slot<ProcessKillParams>()
every {
HermesWsClient.request(WsMethods.PROCESS_LIST, any())
} answers {
coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, any(), any(), any()) } answers {
listCalls++
CompletableDeferred(mapOf("processes" to emptyList<Any>()))
buildJsonObject { put("processes", JsonArray(emptyList())) }
}
every {
HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot))
} answers {
CompletableDeferred(mapOf("killed" to true))
coEvery { HermesWsClient.call(RpcMethods.PROCESS_KILL, capture(killParamsSlot), any(), any()) } answers {
buildJsonObject { put("killed", true) }
}
val vm = ProcessesViewModel()
vm.kill("p1")
val killParams = killParamsSlot.captured
assertEquals("p1", killParams["process_id"])
assertEquals("sess-1", killParams["session_id"])
assertEquals(ProcessKillParams(sessionId = "sess-1", processId = "p1"), killParamsSlot.captured)
// list is called once on init (session emitted) + once after kill refresh
assertTrue(listCalls >= 2)
assertNull(vm.uiState.value.killingId)
@@ -0,0 +1,75 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionSearchResult
import com.m57.hermescontrol.ui.sessions.components.sourceLabel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionSourceFilterTest {
private val rows =
listOf(
SessionInfo("a", source = "cli"),
SessionInfo("b", source = "CLI"),
SessionInfo("c", source = "desktop"),
SessionInfo("d", source = "api_server"),
SessionInfo("e", source = "desktop", hidden = true),
SessionInfo("f", source = null),
)
@Test
fun availableSources_onlyLoadedVisibleSources_orderedByCountThenName() {
val state = SessionsUiState(sessions = rows)
assertEquals(listOf("cli", "api_server", "desktop"), state.availableSources)
}
@Test
fun availableSources_includesHiddenOnlyWhenShown() {
val onlyHidden = listOf(SessionInfo("x", source = "tui", hidden = true), SessionInfo("y", source = "cli"))
assertEquals(listOf("cli"), SessionsUiState(sessions = onlyHidden).availableSources)
assertEquals(
listOf("cli", "tui"),
SessionsUiState(sessions = onlyHidden, showHidden = true).availableSources,
)
}
@Test
fun filter_isCaseInsensitiveAndNarrowsList() {
val state = SessionsUiState(sessions = rows, sourceFilter = "cli")
assertEquals(listOf("a", "b"), state.displaySessions.map { it.id })
}
@Test
fun staleFilter_fallsBackToAll() {
val state = SessionsUiState(sessions = rows, sourceFilter = "telegram")
assertNull(state.activeSourceFilter)
assertEquals(5, state.displaySessions.size)
}
@Test
fun searchMode_usesSearchResultSources() {
val state =
SessionsUiState(
searchQuery = "q",
sessions = rows,
searchResults =
listOf(
SessionSearchResult("s1", source = "telegram"),
SessionSearchResult("s2", source = "cli"),
),
sourceFilter = "telegram",
)
assertEquals(listOf("cli", "telegram"), state.availableSources)
assertEquals(listOf("s1"), displayedSessions(state).map { it.session.id })
}
@Test
fun sourceLabel_mapsKnownAndTitleCasesUnknown() {
assertEquals("API", sourceLabel("api_server"))
assertEquals("Desktop", sourceLabel("desktop"))
assertEquals("TUI", sourceLabel("tui"))
assertEquals("Whatsapp cloud", sourceLabel("whatsapp_cloud"))
assertEquals("Unknown", sourceLabel(null))
}
}
@@ -93,6 +93,8 @@ class SettingsViewModelTest {
every { AuthManager.isTokensPerSecondEnabled() } returns true
every { AuthManager.isModelProviderShown() } returns false
every { AuthManager.isKeepConnectedInBackground() } returns false
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.setNotifySessionCompletions(any()) } returns Unit
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"