refactor(#1375): typed params for session.create, session.resume, prompt.submit (#1381)

This commit is contained in:
m57 authored and GitHub committed 2026-09-29 16:31:03 +03:00
1 parent d466414563
commit a780df3261
9 files changed
+942 -90

No files matched your search

@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethod
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
@@ -827,8 +828,10 @@ object HermesWsClient {
/**
* Send a typed JSON-RPC request and await its deserialized result.
*
* Params classes are strict writers, result classes are tolerant readers, and
* profile is never a field in params (it is injected by WsProfileParams).
* Params classes are strict writers; `profile` is a field ONLY on methods
* where callers pass an explicit override (e.g. session.create, session.resume);
* otherwise it is injected by WsProfileParams (an explicit value wins).
* Result classes are tolerant readers.
*/
suspend fun <P, R> call(
method: RpcMethod<P, R>,
@@ -836,10 +839,7 @@ object HermesWsClient {
timeoutMs: Long = REQUEST_TIMEOUT_MS,
suppressErrorEvent: Boolean = false,
): R {
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
require(encoded is JsonObject) {
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
}
val encoded = encodeParams(method, params)
val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent)
val result =
try {
@@ -852,6 +852,17 @@ object HermesWsClient {
return OkHttpProvider.json.decodeFromJsonElement(method.result, element)
}
private fun <P, R> encodeParams(
method: RpcMethod<P, R>,
params: P,
): JsonObject {
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
require(encoded is JsonObject) {
"RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
}
return encoded
}
/** Complete (or fail) a single pending call and cancel its timer. */
private fun resolvePending(
id: String,
@@ -957,6 +968,21 @@ object HermesWsClient {
onSent: ((String) -> Unit)? = null,
): String = send(method, params, onSent, true)
/**
* Send a typed fire-and-forget JSON-RPC request.
*
* Params classes are strict writers; `profile` is a field ONLY on methods
* where callers pass an explicit override (session.create, session.resume);
* otherwise it is injected by WsProfileParams (an explicit value wins).
*
* @return the request id used (can be matched against [WsEvent.RpcResult]).
*/
fun <P, R> send(
method: RpcMethod<P, R>,
params: P,
onSent: ((String) -> Unit)? = null,
): String = send(method.name, encodeParams(method, params), onSent)
private fun send(
method: String,
params: Map<String, Any>,
@@ -1065,23 +1091,17 @@ object HermesWsClient {
text: String,
onSent: ((String) -> Unit)? = null,
queued: Boolean = false,
): String {
val params =
buildMap {
put("session_id", sessionId)
put("text", text)
// Explicit queue semantics: the gateway's busy-input policy
// forces "run after, never interrupt" when prompt.submit
// carries queued=true (hermes-agent methods_prompt.py
// _handle_busy_submit) — used by /queue.
if (queued) put("queued", true)
}
return send(
method = WsMethods.PROMPT_SUBMIT,
params = params,
): String =
send(
method = RpcMethods.PROMPT_SUBMIT,
params =
PromptSubmitParams(
sessionId = sessionId,
text = text,
queued = queued.takeIf { it },
),
onSent = onSent,
)
}
/**
* Convenience: redirect the active model turn while it is still generating
@@ -17,6 +17,33 @@ object RpcMethods {
SessionEventsSinceResult.serializer(),
)
val SESSION_CREATE: RpcMethod<SessionCreateParams, SessionCreateResult> =
RpcMethod(
WsMethods.SESSION_CREATE,
SessionCreateParams.serializer(),
SessionCreateResult.serializer(),
)
val SESSION_RESUME: RpcMethod<SessionResumeParams, SessionResumeResult> =
RpcMethod(
WsMethods.SESSION_RESUME,
SessionResumeParams.serializer(),
SessionResumeResult.serializer(),
)
val PROMPT_SUBMIT: RpcMethod<PromptSubmitParams, PromptSubmitResult> =
RpcMethod(
WsMethods.PROMPT_SUBMIT,
PromptSubmitParams.serializer(),
PromptSubmitResult.serializer(),
)
/** Every typed method. GatewayContractTest iterates this; future migrations append here. */
val all: List<RpcMethod<*, *>> = listOf(SESSION_EVENTS_SINCE)
val all: List<RpcMethod<*, *>> =
listOf(
SESSION_EVENTS_SINCE,
SESSION_CREATE,
SESSION_RESUME,
PROMPT_SUBMIT,
)
}
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.ws.contract
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SessionCreateParams(
@SerialName("source") val source: String? = null,
@SerialName("profile") val profile: String? = null,
@SerialName("title") val title: String? = null,
@SerialName("hidden") val hidden: Boolean? = null,
)
@Serializable
data class SessionResumeParams(
@SerialName("session_id") val sessionId: String,
@SerialName("omit_messages") val omitMessages: Boolean? = null,
@SerialName("profile") val profile: String? = null,
)
@Serializable
data class PromptSubmitParams(
@SerialName("session_id") val sessionId: String,
@SerialName("text") val text: String,
@SerialName("queued") val queued: Boolean? = null,
)
// tolerant readers: all nullable with null defaults, only fields callers read
@Serializable
data class SessionCreateResult(
@SerialName("session_id") val sessionId: String? = null,
@SerialName("stored_session_id") val storedSessionId: String? = null,
)
@Serializable
data class SessionResumeResult(
@SerialName("session_id") val sessionId: String? = null,
)
@Serializable
data class PromptSubmitResult(
val status: String? = null,
)
@@ -11,7 +11,9 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -109,12 +111,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeSessionId, "text" to replyText),
timeoutMs = REPLY_TIMEOUT_MS,
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeSessionId, text = replyText),
timeoutMs = REPLY_TIMEOUT_MS,
)
val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity(
@@ -152,24 +153,21 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
}
private suspend fun resumeSession(storedSessionId: String): String {
val profile = AuthManager.activeProfileId.value
val profile = AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
val params =
mutableMapOf<String, Any>(
"session_id" to storedSessionId,
"omit_messages" to true,
SessionResumeParams(
sessionId = storedSessionId,
omitMessages = true,
profile = profile,
)
if (!profile.isNullOrBlank()) {
params["profile"] = profile
}
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
params,
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
HermesWsClient.call(
method = RpcMethods.SESSION_RESUME,
params = params,
timeoutMs = REPLY_TIMEOUT_MS,
)
val runtimeSessionId =
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
result.sessionId?.takeIf { it.isNotBlank() }
?: error("Resume returned no runtime session id")
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
return runtimeSessionId
@@ -17,6 +17,9 @@ import com.m57.hermescontrol.data.session.ActiveSessionHolder
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.PromptSubmitParams
import com.m57.hermescontrol.data.ws.contract.RpcMethods
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.ui.chat.tool.ToolResultSummary
import kotlinx.coroutines.CompletableDeferred
@@ -559,17 +562,24 @@ class GroupChatViewModel(
val title = "Group: $groupName"
try {
val createParams =
buildMap<String, Any> {
put("profile", bot.name)
put("title", title)
put("source", "desktop")
put("hidden", true)
}
val deferred = HermesWsClient.request(WsMethods.SESSION_CREATE, createParams)
val res = deferred.await()
val sessionInfo = extractSessionInfo(res)
if (sessionInfo != null) {
val res =
HermesWsClient.call(
method = RpcMethods.SESSION_CREATE,
params =
SessionCreateParams(
source = "desktop",
profile = bot.name,
title = title,
hidden = true,
),
)
val runtimeId = res.sessionId?.takeIf { it.isNotBlank() }
if (runtimeId != null) {
val sessionInfo =
MemberSession(
runtimeSessionId = runtimeId,
storedSessionId = res.storedSessionId,
)
memberSessions[bot.name] = sessionInfo
return sessionInfo
}
@@ -701,11 +711,10 @@ class GroupChatViewModel(
// turn boundary can be proven for them. They stay uncorrelated
// (reply notifications from group turns are never auto-dismissed
// from REST hydration) instead of borrowing another turn's bound.
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
)
} catch (submitErr: Exception) {
Log.w(
"GroupChatViewModel",
@@ -735,11 +744,10 @@ class GroupChatViewModel(
storedId?.let { inFlightTurns[it] = turnDeferred }
try {
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeId, "text" to prompt),
).await()
HermesWsClient.call(
method = RpcMethods.PROMPT_SUBMIT,
params = PromptSubmitParams(sessionId = runtimeId, text = prompt),
)
} catch (retryErr: Exception) {
Log.e("GroupChatViewModel", "Retry prompt.submit failed for ${bot.name}: ${retryErr.message}")
return null
@@ -1202,19 +1210,4 @@ class GroupChatViewModel(
val clean = text.trim().lowercase()
return clean == "(pass)" || clean == "pass" || clean == "(pass)." || clean == "pass."
}
private fun extractSessionInfo(response: Any?): MemberSession? {
if (response is Map<*, *>) {
val runtimeId =
response["session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("session_id")?.toString()?.trim('\"', ' ')
val storedId =
response["stored_session_id"]?.toString()?.trim('\"', ' ')
?: (response["result"] as? Map<*, *>)?.get("stored_session_id")?.toString()?.trim('\"', ' ')
if (!runtimeId.isNullOrBlank()) {
return MemberSession(runtimeSessionId = runtimeId, storedSessionId = storedId)
}
}
return null
}
}