refactor(#1375): typed RPC foundation and session.events.since (#1380)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 15:35:10 +03:00
1 parent d07325cd31
commit d466414563
9 files changed
+1377 -51

No files matched your search

@@ -1,8 +1,10 @@
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
@@ -70,6 +72,82 @@ object GatewayContract {
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()})"
}
}
val missingRequired =
required.filter { reqName ->
reqName !in elementNames && reqName != "profile"
}
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
}
}
/**
@@ -1,5 +1,6 @@
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
@@ -75,6 +76,20 @@ class GatewayContractTest {
}
}
@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 =
@@ -0,0 +1,534 @@
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.ConcurrentHashMap
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 } }
}
}
@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>
}
@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))
val pending = getPendingCalls()
assertEquals("Should have exactly 1 pending call while awaiting", 1, pending.size)
callJob.cancel()
// Wait briefly for cancellation cleanup to execute
withTimeout(2000) {
while (pending.isNotEmpty()) {
kotlinx.coroutines.delay(20)
}
}
assertTrue("Pending calls should be empty after caller coroutine is cancelled", pending.isEmpty())
}
}
}
@@ -0,0 +1,402 @@
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 unmigrated raw RPC call sites in `app/src/main/java/com/m57/hermescontrol/`.
* Excludes `data/ws/HermesWsClient.kt::SESSION_EVENTS_SINCE` which is migrated to typed `RpcMethods`.
* Notice HermesWsClient.kt retains its other raw uses: `CLIENT_CAPABILITIES`, `PROMPT_SUBMIT`,
* and `SESSION_REDIRECT`.
*/
val BASELINE: List<String> =
listOf(
"data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_ACCOUNTS_REMOVE",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_CATALOG",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_CONNECT",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_LIST",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_OPERATION_STATUS",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_GET",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_POLICY_SET",
"data/ws/AccountConnectorRepository.kt::CONNECTORS_TOOLS",
"data/ws/BillingRepository.kt::SUBSCRIPTION_CHANGE",
"data/ws/BillingRepository.kt::SUBSCRIPTION_PREVIEW",
"data/ws/BillingRepository.kt::SUBSCRIPTION_RESUME",
"data/ws/BillingRepository.kt::SUBSCRIPTION_STATE",
"data/ws/BillingRepository.kt::SUBSCRIPTION_UPGRADE",
"data/ws/BillingRepository.kt::USAGE_BARS",
"data/ws/ConnectorRepository.kt::CONNECTORS_CONNECT",
"data/ws/ConnectorRepository.kt::CONNECTORS_LIST",
"data/ws/HermesWsClient.kt::CLIENT_CAPABILITIES",
"data/ws/HermesWsClient.kt::PROMPT_SUBMIT",
"data/ws/HermesWsClient.kt::SESSION_REDIRECT",
"data/ws/ModelOptionsRepository.kt::MODEL_OPTIONS",
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
"data/ws/PluginManageRepository.kt::PLUGINS_MANAGE",
"data/ws/PluginRemovalRepository.kt::PLUGINS_MANAGE",
"data/ws/ProjectsSource.kt::PROJECTS_LIST",
"data/ws/SessionLiveStatusSource.kt::SESSION_ACTIVE_LIST",
"data/ws/SubagentRepository.kt::SUBAGENT_LIST",
"data/ws/SubagentRepository.kt::SUBAGENT_TAIL",
"data/ws/VaultRepository.kt::VAULT_LIST",
"data/ws/VaultRepository.kt::VAULT_LOCK",
"data/ws/VaultRepository.kt::VAULT_SOURCES",
"data/ws/VaultRepository.kt::VAULT_SOURCE_SET",
"data/ws/VaultRepository.kt::VAULT_UNLOCK",
"notification/NotificationReplyReceiver.kt::PROMPT_SUBMIT",
"notification/NotificationReplyReceiver.kt::SESSION_RESUME",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/BotsViewModel.kt::PROFILES_LIST",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_CONFIGURE",
"ui/bots/group/GroupChatViewModel.kt::PROFILES_LIST",
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
"ui/bots/group/GroupChatViewModel.kt::PROMPT_SUBMIT",
"ui/bots/group/GroupChatViewModel.kt::SESSION_CREATE",
"ui/bots/group/GroupChatViewModel.kt::SESSION_INTERRUPT",
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_PENDING",
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RECEIVED",
"ui/chat/ChatApprovalsDelegate.kt::APPROVAL_RESPOND",
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND",
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTION_RESPOND",
"ui/chat/ChatConnectionOperationDelegate.kt::CONNECTORS_OPERATION_WAKE",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatModelSwitchDelegate.kt::CONFIG_SET",
"ui/chat/ChatViewModel.kt::COMMANDS_CATALOG",
"ui/chat/ChatViewModel.kt::COMMAND_DISPATCH",
"ui/chat/ChatViewModel.kt::COMMAND_DISPATCH",
"ui/chat/ChatViewModel.kt::CONFIG_GET",
"ui/chat/ChatViewModel.kt::CONFIG_SET",
"ui/chat/ChatViewModel.kt::FILE_ATTACH",
"ui/chat/ChatViewModel.kt::IMAGE_ATTACH_BYTES",
"ui/chat/ChatViewModel.kt::PROMPT_BTW",
"ui/chat/ChatViewModel.kt::SESSION_BRANCH",
"ui/chat/ChatViewModel.kt::SESSION_BRANCH_WHOLE",
"ui/chat/ChatViewModel.kt::SESSION_COMPRESS",
"ui/chat/ChatViewModel.kt::SESSION_CONTEXT_BREAKDOWN",
"ui/chat/ChatViewModel.kt::SESSION_CREATE",
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
"ui/chat/ChatViewModel.kt::SESSION_INTERRUPT",
"ui/chat/ChatViewModel.kt::SESSION_LIST",
"ui/chat/ChatViewModel.kt::SESSION_RESUME",
"ui/chat/ChatViewModel.kt::SESSION_STEER",
"ui/chat/ChatViewModel.kt::SESSION_USAGE",
"ui/chat/ChatViewModel.kt::SLASH_EXEC",
"ui/process/ProcessesViewModel.kt::PROCESS_KILL",
"ui/process/ProcessesViewModel.kt::PROCESS_LIST",
)
}
}
@@ -0,0 +1,106 @@
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 omittingProfilePassesEvenWhenRequiredBySchema() {
// bot_relay.deliver has required: ["profile", "message"]
val problems =
GatewayContract.paramsDescriptorProblems(
"bot_relay.deliver",
DeliverMissingProfileParams.serializer().descriptor,
)
assertTrue(problems.joinToString("\n"), problems.isEmpty())
}
}
@@ -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))
}
}