feat: add durable gateway session foundation

This commit is contained in:
Hermes Agent
2026-07-24 11:57:34 +00:00
parent f4ccc70e6e
commit 8e7b5c6b44
19 changed files with 549 additions and 44 deletions
+5
View File
@@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
## Unreleased ## Unreleased
### Added ### Added
- Added B3 foundation contracts for session search plus capability-negotiated session list/create/get/delete requests, without enabling unadvertised gateway routes.
- Added durable Android selected-session, per-session draft, and pending-send state with fake repository/reducer coverage for duplicate-send protection and restoration.
- Added one securely stored Hermes gateway profile with optional Local HTTPS and required Remote HTTPS routes, bearer/no-auth configuration, and visible active-route diagnostics. - Added one securely stored Hermes gateway profile with optional Local HTTPS and required Remote HTTPS routes, bearer/no-auth configuration, and visible active-route diagnostics.
- Added bounded, redirect-disabled `GET /health` probes plus authenticated `GET /v1/models` compatibility checks and deterministic route, retry, connection-state, and HTTP-header tests. - Added bounded, redirect-disabled `GET /health` probes plus authenticated `GET /v1/models` compatibility checks and deterministic route, retry, connection-state, and HTTP-header tests.
- Added foreground, explicit reconnect, connectivity-change, and failed-request re-evaluation hooks backed by immutable per-request route leases. - Added foreground, explicit reconnect, connectivity-change, and failed-request re-evaluation hooks backed by immutable per-request route leases.
@@ -38,6 +40,9 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value. - Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification ### Verification
- Full Android Kotlin/Compose source and unit-test compilation through the cached Kotlin compiler plus direct JUnit execution — passed, 37 tests including B3 session capability mapping, durable chat JSON/state, fake chat reducer, and fake repositories.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest` — blocked before compilation because this sandbox prohibits Gradle daemon/control sockets (`java.net.SocketException: Operation not permitted`).
- `npm run typecheck && npm run lint && npm run build` — passed for all workspaces.
- Direct Kotlin/JUnit execution of `ConnectionStateTest` and `GatewayHttpProbeTest` — passed, 12 tests. - Direct Kotlin/JUnit execution of `ConnectionStateTest` and `GatewayHttpProbeTest` — passed, 12 tests.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugUnitTestKotlin :app:assembleDebug` from `apps/mobile/android` — passed with temporary restored sandbox-only Gradle socket shims; 37 tasks, 6 executed. - `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --no-daemon -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugUnitTestKotlin :app:assembleDebug` from `apps/mobile/android` — passed with temporary restored sandbox-only Gradle socket shims; 37 tasks, 6 executed.
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest` — test sources compiled, but Gradle's forked test worker could not open its sandbox-prohibited TCP control socket; the same compiled test classes passed through direct JUnit execution. - `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest` — test sources compiled, but Gradle's forked test worker could not open its sandbox-prohibited TCP control socket; the same compiled test classes passed through direct JUnit execution.
+2
View File
@@ -30,6 +30,8 @@ Complete the Android/client contract pivot before implementing backend behavior.
- Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified. - Fetch/list/search/select/create sessions through repository contracts after upstream session routes are verified.
- Restore selected session and protect against duplicate sends / lost drafts. - Restore selected session and protect against duplicate sends / lost drafts.
- Verification: fake-repository UI tests; direct gateway compatibility checks where available. - Verification: fake-repository UI tests; direct gateway compatibility checks where available.
- Foundation verified 2026-07-24: repository search contract and fake implementation, capability-negotiated session list/create/get/delete request mapping, durable selected-session/draft/pending-send storage, and duplicate-send reducer/UI protection.
- Still blocked: official upstream docs do not document a session-search endpoint or query schema, and no live gateway compatibility exercise is available in this sandbox. Keep B3 unchecked until search and real gateway behavior are verified.
- **Queued after B3 — B4: Direct gateway streaming, reconnects, and resilient long-running work** - **Queued after B3 — B4: Direct gateway streaming, reconnects, and resilient long-running work**
- Stream event reducer: final response/token deltas first; tool lifecycle, session events, artifacts, cancel, and reconnect/backoff only where upstream capability is verified. - Stream event reducer: final response/token deltas first; tool lifecycle, session events, artifacts, cancel, and reconnect/backoff only where upstream capability is verified.
@@ -40,7 +40,8 @@ data class ChatSessionUi(
val subtitle: String, val subtitle: String,
val messages: List<ChatMessageUi>, val messages: List<ChatMessageUi>,
val draft: String = "", val draft: String = "",
val busy: Boolean = false val busy: Boolean = false,
val pendingSendId: String? = null
) )
data class FakeChatUiState( data class FakeChatUiState(
@@ -53,4 +54,3 @@ data class FakeChatUiState(
val selectedSession: ChatSessionUi? val selectedSession: ChatSessionUi?
get() = sessions.firstOrNull { it.id == selectedSessionId } get() = sessions.firstOrNull { it.id == selectedSessionId }
} }
@@ -1,5 +1,6 @@
package cloud.molberg.hermesmobile.chat package cloud.molberg.hermesmobile.chat
import cloud.molberg.hermesmobile.domain.DurableChatState
import cloud.molberg.hermesmobile.fake.FakeHermesRepository import cloud.molberg.hermesmobile.fake.FakeHermesRepository
object FakeChatReducer { object FakeChatReducer {
@@ -61,6 +62,20 @@ object FakeChatReducer {
fun select(state: FakeChatUiState, sessionId: String): FakeChatUiState = fun select(state: FakeChatUiState, sessionId: String): FakeChatUiState =
state.copy(selectedSessionId = sessionId, connectionState = ChatConnectionState.Ready) state.copy(selectedSessionId = sessionId, connectionState = ChatConnectionState.Ready)
fun restore(state: FakeChatUiState, durable: DurableChatState): FakeChatUiState {
val sessions = state.sessions.map { session ->
session.copy(
draft = durable.drafts[session.id].orEmpty(),
busy = session.busy || durable.pendingSends.containsKey(session.id),
pendingSendId = durable.pendingSends[session.id]?.requestId
)
}
val selectedSessionId = durable.selectedSessionId?.takeIf { selected ->
sessions.any { it.id == selected }
} ?: state.selectedSessionId
return state.copy(sessions = sessions, selectedSessionId = selectedSessionId)
}
fun newSession(state: FakeChatUiState, id: String): FakeChatUiState { fun newSession(state: FakeChatUiState, id: String): FakeChatUiState {
val session = ChatSessionUi( val session = ChatSessionUi(
id = id, id = id,
@@ -91,6 +106,8 @@ object FakeChatReducer {
fun send(state: FakeChatUiState, sessionId: String, prompt: String, messageId: String): FakeChatUiState { fun send(state: FakeChatUiState, sessionId: String, prompt: String, messageId: String): FakeChatUiState {
val trimmed = prompt.trim() val trimmed = prompt.trim()
if (trimmed.isBlank()) return state if (trimmed.isBlank()) return state
val currentSession = state.sessions.firstOrNull { it.id == sessionId } ?: return state
if (currentSession.busy || currentSession.pendingSendId != null) return state
val assistantId = "$messageId-assistant" val assistantId = "$messageId-assistant"
return updateSession(state, sessionId) { session -> return updateSession(state, sessionId) { session ->
val user = ChatMessageUi( val user = ChatMessageUi(
@@ -111,7 +128,8 @@ object FakeChatReducer {
subtitle = "Hermes is streaming...", subtitle = "Hermes is streaming...",
messages = session.messages + user + assistant, messages = session.messages + user + assistant,
draft = "", draft = "",
busy = true busy = true,
pendingSendId = messageId
) )
}.copy(connectionState = ChatConnectionState.Ready, statusText = "Streaming fake response.") }.copy(connectionState = ChatConnectionState.Ready, statusText = "Streaming fake response.")
} }
@@ -135,7 +153,7 @@ object FakeChatReducer {
message message
} }
} }
session.copy(messages = updatedMessages, busy = false, subtitle = "Fake response complete.") session.copy(messages = updatedMessages, busy = false, pendingSendId = null, subtitle = "Fake response complete.")
}.copy(connectionState = ChatConnectionState.Ready, statusText = "Fake response complete.") }.copy(connectionState = ChatConnectionState.Ready, statusText = "Fake response complete.")
fun cancel(state: FakeChatUiState, sessionId: String): FakeChatUiState = fun cancel(state: FakeChatUiState, sessionId: String): FakeChatUiState =
@@ -153,14 +171,19 @@ object FakeChatReducer {
message message
} }
} }
session.copy(messages = updatedMessages, busy = false, subtitle = "Cancelled") session.copy(messages = updatedMessages, busy = false, pendingSendId = null, subtitle = "Cancelled")
}.copy(statusText = "Generation cancelled.") }.copy(statusText = "Generation cancelled.")
fun retry(state: FakeChatUiState, sessionId: String, messageId: String, nextMessageId: String): FakeChatUiState { fun retry(state: FakeChatUiState, sessionId: String, messageId: String, nextMessageId: String): FakeChatUiState {
val session = state.sessions.firstOrNull { it.id == sessionId } ?: return state val session = state.sessions.firstOrNull { it.id == sessionId } ?: return state
val prompt = session.messages.firstOrNull { it.id == messageId }?.retryPrompt ?: return state val prompt = session.messages.firstOrNull { it.id == messageId }?.retryPrompt ?: return state
val cleared = updateSession(state, sessionId) { existing -> val cleared = updateSession(state, sessionId) { existing ->
existing.copy(messages = existing.messages.filterNot { it.id == messageId }, draft = prompt, busy = false) existing.copy(
messages = existing.messages.filterNot { it.id == messageId },
draft = prompt,
busy = false,
pendingSendId = null
)
} }
return send(cleared, sessionId, prompt, nextMessageId) return send(cleared, sessionId, prompt, nextMessageId)
} }
@@ -55,6 +55,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -75,37 +76,79 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) { fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var state by remember { mutableStateOf(FakeChatReducer.seed()) } val chatStateRepository = remember(context) { StoredChatStateRepository(context) }
var state by remember {
mutableStateOf(FakeChatReducer.restore(FakeChatReducer.seed(), chatStateRepository.load(FAKE_PROFILE_ID)))
}
var nextId by remember { mutableIntStateOf(10) } var nextId by remember { mutableIntStateOf(10) }
var streamJob by remember { mutableStateOf<Job?>(null) } val streamJobs = remember { mutableMapOf<String, Job>() }
fun createSession() { fun createSession() {
val id = "local-${nextId++}" val id = "local-${nextId++}"
state = FakeChatReducer.newSession(state, id) state = FakeChatReducer.newSession(state, id)
chatStateRepository.selectSession(FAKE_PROFILE_ID, id)
}
fun selectSession(sessionId: String) {
state = FakeChatReducer.select(state, sessionId)
chatStateRepository.selectSession(FAKE_PROFILE_ID, sessionId)
}
fun updateDraft(sessionId: String, draft: String) {
state = FakeChatReducer.updateDraft(state, sessionId, draft)
chatStateRepository.saveDraft(FAKE_PROFILE_ID, sessionId, draft)
} }
fun send(session: ChatSessionUi, prompt: String = session.draft) { fun send(session: ChatSessionUi, prompt: String = session.draft) {
if (prompt.isBlank() || session.busy) return if (prompt.isBlank() || session.busy) return
streamJob?.cancel() streamJobs.remove(session.id)?.cancel()
val messageId = "m-${nextId++}" val messageId = "m-${nextId++}"
if (!chatStateRepository.reserveSend(FAKE_PROFILE_ID, session.id, messageId, prompt.trim())) {
notify("A send is already pending for this chat.")
return
}
val assistantId = "$messageId-assistant" val assistantId = "$messageId-assistant"
state = FakeChatReducer.send(state, session.id, prompt, messageId) state = FakeChatReducer.send(state, session.id, prompt, messageId)
streamJob = scope.launch { chatStateRepository.saveDraft(FAKE_PROFILE_ID, session.id, "")
streamJobs[session.id] = scope.launch {
delay(450) delay(450)
state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nChecking context controls...") state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nChecking context controls...")
delay(450) delay(450)
state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nComposing markdown and code surfaces...") state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nComposing markdown and code surfaces...")
delay(450) delay(450)
state = FakeChatReducer.finishStream(state, session.id, assistantId, prompt) state = FakeChatReducer.finishStream(state, session.id, assistantId, prompt)
chatStateRepository.finishSend(FAKE_PROFILE_ID, session.id, messageId)
streamJobs.remove(session.id)
} }
} }
fun cancel(session: ChatSessionUi) { fun cancel(session: ChatSessionUi) {
streamJob?.cancel() streamJobs.remove(session.id)?.cancel()
session.pendingSendId?.let { chatStateRepository.finishSend(FAKE_PROFILE_ID, session.id, it) }
state = FakeChatReducer.cancel(state, session.id) state = FakeChatReducer.cancel(state, session.id)
} }
fun retry(sessionId: String, messageId: String) {
val session = state.sessions.firstOrNull { it.id == sessionId } ?: return
val prompt = session.messages.firstOrNull { it.id == messageId }?.retryPrompt ?: return
val retryId = "m-${nextId++}"
if (!chatStateRepository.reserveSend(FAKE_PROFILE_ID, sessionId, retryId, prompt)) {
notify("A send is already pending for this chat.")
return
}
streamJobs.remove(sessionId)?.cancel()
state = FakeChatReducer.retry(state, sessionId, messageId, retryId)
chatStateRepository.saveDraft(FAKE_PROFILE_ID, sessionId, "")
streamJobs[sessionId] = scope.launch {
delay(500)
state = FakeChatReducer.finishStream(state, sessionId, "$retryId-assistant", prompt)
chatStateRepository.finishSend(FAKE_PROFILE_ID, sessionId, retryId)
streamJobs.remove(sessionId)
}
}
LaunchedEffect(resetToken) { LaunchedEffect(resetToken) {
if (resetToken > 0) createSession() if (resetToken > 0) createSession()
} }
@@ -125,7 +168,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
.width(304.dp) .width(304.dp)
.fillMaxHeight(), .fillMaxHeight(),
onNew = ::createSession, onNew = ::createSession,
onSelect = { state = FakeChatReducer.select(state, it) }, onSelect = ::selectSession,
onState = { state = it } onState = { state = it }
) )
ChatConversationPane( ChatConversationPane(
@@ -133,27 +176,10 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
session = selected, session = selected,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
onBack = null, onBack = null,
onDraft = { id, draft -> state = FakeChatReducer.updateDraft(state, id, draft) }, onDraft = ::updateDraft,
onSend = ::send, onSend = ::send,
onCancel = ::cancel, onCancel = ::cancel,
onRetry = { sessionId, messageId -> onRetry = ::retry,
val retryId = "m-${nextId++}"
streamJob?.cancel()
state = FakeChatReducer.retry(state, sessionId, messageId, retryId)
state.selectedSession?.let { retrySession ->
val prompt = retrySession.messages.lastOrNull { it.role == ChatRole.User }
?.parts
?.filterIsInstance<MessagePartUi.Text>()
?.firstOrNull()
?.text
.orEmpty()
val assistantId = "$retryId-assistant"
streamJob = scope.launch {
delay(500)
state = FakeChatReducer.finishStream(state, sessionId, assistantId, prompt)
}
}
},
onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) }, onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) },
onContext = { state = FakeChatReducer.updateContext(state, it) }, onContext = { state = FakeChatReducer.updateContext(state, it) },
notify = notify notify = notify
@@ -164,7 +190,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
state = state, state = state,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onNew = ::createSession, onNew = ::createSession,
onSelect = { state = FakeChatReducer.select(state, it) }, onSelect = ::selectSession,
onState = { state = it } onState = { state = it }
) )
} else { } else {
@@ -173,20 +199,10 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
session = selected, session = selected,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
onBack = { state = state.copy(selectedSessionId = null) }, onBack = { state = state.copy(selectedSessionId = null) },
onDraft = { id, draft -> state = FakeChatReducer.updateDraft(state, id, draft) }, onDraft = ::updateDraft,
onSend = ::send, onSend = ::send,
onCancel = ::cancel, onCancel = ::cancel,
onRetry = { sessionId, messageId -> onRetry = ::retry,
val retryId = "m-${nextId++}"
streamJob?.cancel()
state = FakeChatReducer.retry(state, sessionId, messageId, retryId)
streamJob = scope.launch {
delay(500)
val prompt = state.selectedSession?.messages?.lastOrNull { it.role == ChatRole.User }
?.parts?.filterIsInstance<MessagePartUi.Text>()?.firstOrNull()?.text.orEmpty()
state = FakeChatReducer.finishStream(state, sessionId, "$retryId-assistant", prompt)
}
},
onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) }, onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) },
onContext = { state = FakeChatReducer.updateContext(state, it) }, onContext = { state = FakeChatReducer.updateContext(state, it) },
notify = notify notify = notify
@@ -195,6 +211,8 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
} }
} }
private const val FAKE_PROFILE_ID = "profile-local"
@Composable @Composable
private fun ChatSessionRail( private fun ChatSessionRail(
state: FakeChatUiState, state: FakeChatUiState,
@@ -0,0 +1,103 @@
package cloud.molberg.hermesmobile.chat
import android.content.Context
import cloud.molberg.hermesmobile.domain.ChatStateRepository
import cloud.molberg.hermesmobile.domain.DurableChatState
import cloud.molberg.hermesmobile.domain.PendingChatSend
import org.json.JSONObject
class StoredChatStateRepository(context: Context) : ChatStateRepository {
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
@Synchronized
override fun load(profileId: String): DurableChatState =
ChatStateJson.decode(preferences.getString(key(profileId), null))
@Synchronized
override fun selectSession(profileId: String, sessionId: String?) {
update(profileId) { it.copy(selectedSessionId = sessionId) }
}
@Synchronized
override fun saveDraft(profileId: String, sessionId: String, draft: String) {
update(profileId) { state ->
val drafts = state.drafts.toMutableMap()
if (draft.isBlank()) drafts.remove(sessionId) else drafts[sessionId] = draft
state.copy(drafts = drafts)
}
}
@Synchronized
override fun reserveSend(profileId: String, sessionId: String, requestId: String, prompt: String): Boolean {
val state = load(profileId)
if (state.pendingSends.containsKey(sessionId)) return false
persist(
profileId,
state.copy(pendingSends = state.pendingSends + (sessionId to PendingChatSend(requestId, prompt)))
)
return true
}
@Synchronized
override fun finishSend(profileId: String, sessionId: String, requestId: String) {
update(profileId) { state ->
if (state.pendingSends[sessionId]?.requestId != requestId) state
else state.copy(pendingSends = state.pendingSends - sessionId)
}
}
@Synchronized
override fun clearSession(profileId: String, sessionId: String) {
update(profileId) { state ->
state.copy(
selectedSessionId = state.selectedSessionId.takeUnless { it == sessionId },
drafts = state.drafts - sessionId,
pendingSends = state.pendingSends - sessionId
)
}
}
private fun update(profileId: String, transform: (DurableChatState) -> DurableChatState) {
persist(profileId, transform(load(profileId)))
}
private fun persist(profileId: String, state: DurableChatState) {
preferences.edit().putString(key(profileId), ChatStateJson.encode(state)).apply()
}
private fun key(profileId: String): String = "chat-state:$profileId"
private companion object {
const val PREFERENCES_NAME = "hermes-chat-state"
}
}
object ChatStateJson {
fun encode(state: DurableChatState): String = JSONObject().apply {
put("selectedSessionId", state.selectedSessionId)
put("drafts", JSONObject(state.drafts))
put("pendingSends", JSONObject().apply {
state.pendingSends.forEach { (sessionId, pending) ->
put(sessionId, JSONObject().put("requestId", pending.requestId).put("prompt", pending.prompt))
}
})
}.toString()
fun decode(value: String?): DurableChatState {
if (value.isNullOrBlank()) return DurableChatState()
return runCatching {
val json = JSONObject(value)
val draftsJson = json.optJSONObject("drafts") ?: JSONObject()
val pendingJson = json.optJSONObject("pendingSends") ?: JSONObject()
DurableChatState(
selectedSessionId = json.optString("selectedSessionId").takeIf { it.isNotBlank() },
drafts = draftsJson.keys().asSequence().associateWith { draftsJson.getString(it) },
pendingSends = pendingJson.keys().asSequence().associateWith { sessionId ->
pendingJson.getJSONObject(sessionId).let { pending ->
PendingChatSend(pending.getString("requestId"), pending.getString("prompt"))
}
}
)
}.getOrDefault(DurableChatState())
}
}
@@ -0,0 +1,21 @@
package cloud.molberg.hermesmobile.domain
data class PendingChatSend(
val requestId: String,
val prompt: String
)
data class DurableChatState(
val selectedSessionId: String? = null,
val drafts: Map<String, String> = emptyMap(),
val pendingSends: Map<String, PendingChatSend> = emptyMap()
)
interface ChatStateRepository {
fun load(profileId: String): DurableChatState
fun selectSession(profileId: String, sessionId: String?)
fun saveDraft(profileId: String, sessionId: String, draft: String)
fun reserveSend(profileId: String, sessionId: String, requestId: String, prompt: String): Boolean
fun finishSend(profileId: String, sessionId: String, requestId: String)
fun clearSession(profileId: String, sessionId: String)
}
@@ -17,6 +17,7 @@ interface AuthRepository {
interface HermesSessionRepository { interface HermesSessionRepository {
suspend fun listSessions(profileId: String): List<HermesSession> suspend fun listSessions(profileId: String): List<HermesSession>
suspend fun searchSessions(profileId: String, query: String): List<HermesSession>
suspend fun getSession(profileId: String, sessionId: String): HermesSession? suspend fun getSession(profileId: String, sessionId: String): HermesSession?
suspend fun createSession(profileId: String, title: String? = null): HermesSession suspend fun createSession(profileId: String, title: String? = null): HermesSession
suspend fun deleteSession(profileId: String, sessionId: String) suspend fun deleteSession(profileId: String, sessionId: String)
@@ -0,0 +1,57 @@
package cloud.molberg.hermesmobile.fake
import cloud.molberg.hermesmobile.domain.ChatStateRepository
import cloud.molberg.hermesmobile.domain.DurableChatState
import cloud.molberg.hermesmobile.domain.PendingChatSend
class FakeChatStateRepository(initial: Map<String, DurableChatState> = emptyMap()) : ChatStateRepository {
private val states = initial.toMutableMap()
@Synchronized
override fun load(profileId: String): DurableChatState = states[profileId] ?: DurableChatState()
@Synchronized
override fun selectSession(profileId: String, sessionId: String?) {
update(profileId) { it.copy(selectedSessionId = sessionId) }
}
@Synchronized
override fun saveDraft(profileId: String, sessionId: String, draft: String) {
update(profileId) { state ->
state.copy(drafts = if (draft.isBlank()) state.drafts - sessionId else state.drafts + (sessionId to draft))
}
}
@Synchronized
override fun reserveSend(profileId: String, sessionId: String, requestId: String, prompt: String): Boolean {
val state = load(profileId)
if (state.pendingSends.containsKey(sessionId)) return false
states[profileId] = state.copy(
pendingSends = state.pendingSends + (sessionId to PendingChatSend(requestId, prompt))
)
return true
}
@Synchronized
override fun finishSend(profileId: String, sessionId: String, requestId: String) {
update(profileId) { state ->
if (state.pendingSends[sessionId]?.requestId != requestId) state
else state.copy(pendingSends = state.pendingSends - sessionId)
}
}
@Synchronized
override fun clearSession(profileId: String, sessionId: String) {
update(profileId) { state ->
state.copy(
selectedSessionId = state.selectedSessionId.takeUnless { it == sessionId },
drafts = state.drafts - sessionId,
pendingSends = state.pendingSends - sessionId
)
}
}
private fun update(profileId: String, transform: (DurableChatState) -> DurableChatState) {
states[profileId] = transform(load(profileId))
}
}
@@ -96,6 +96,15 @@ class FakeHermesRepository : HermesRepository {
emptyList() emptyList()
} }
override suspend fun searchSessions(profileId: String, query: String): List<HermesSession> {
val normalized = query.trim()
if (normalized.isBlank()) return listSessions(profileId)
return listSessions(profileId).filter { session ->
session.title.contains(normalized, ignoreCase = true) ||
session.subtitle.contains(normalized, ignoreCase = true)
}
}
override suspend fun getSession(profileId: String, sessionId: String): HermesSession? = override suspend fun getSession(profileId: String, sessionId: String): HermesSession? =
sessions[sessionId].takeIf { profiles.containsKey(profileId) } sessions[sessionId].takeIf { profiles.containsKey(profileId) }
@@ -0,0 +1,73 @@
package cloud.molberg.hermesmobile.session
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
data class GatewaySessionEndpoints(
val list: String? = null,
val create: String? = null,
val get: String? = null,
val delete: String? = null
) {
companion object {
fun fromCapabilities(json: JSONObject): GatewaySessionEndpoints {
val endpoints = json.optJSONObject("endpoints") ?: JSONObject()
return GatewaySessionEndpoints(
list = endpoints.nonBlankString("session_list"),
create = endpoints.nonBlankString("session_create"),
get = endpoints.nonBlankString("session_get"),
delete = endpoints.nonBlankString("session_delete")
)
}
}
}
class GatewaySessionContract(private val endpoints: GatewaySessionEndpoints) {
fun listRequest(baseUrl: String, bearerToken: String): Request =
request(baseUrl, requireEndpoint(endpoints.list, "session_list"), bearerToken)
fun createRequest(baseUrl: String, bearerToken: String): Request =
request(baseUrl, requireEndpoint(endpoints.create, "session_create"), bearerToken) {
post(ByteArray(0).toRequestBody())
}
fun getRequest(baseUrl: String, bearerToken: String, sessionId: String): Request =
request(baseUrl, expandSession(requireEndpoint(endpoints.get, "session_get"), sessionId), bearerToken)
fun deleteRequest(baseUrl: String, bearerToken: String, sessionId: String): Request =
request(baseUrl, expandSession(requireEndpoint(endpoints.delete, "session_delete"), sessionId), bearerToken) {
delete()
}
private fun request(
baseUrl: String,
endpoint: String,
bearerToken: String,
configure: Request.Builder.() -> Unit = { get() }
): Request {
require(endpoint.startsWith('/')) { "Gateway capability endpoint must be an absolute path." }
val builder = Request.Builder()
.url(baseUrl.trimEnd('/') + endpoint)
.header("Accept", "application/json")
if (bearerToken.isNotBlank()) builder.header("Authorization", "Bearer $bearerToken")
return builder.apply(configure).build()
}
private fun expandSession(template: String, sessionId: String): String {
require(template.contains("{id}")) { "Gateway session endpoint must document an {id} placeholder." }
return template.replace("{id}", encode(sessionId))
}
private fun requireEndpoint(endpoint: String?, capability: String): String =
requireNotNull(endpoint) { "Gateway does not advertise the $capability capability." }
private fun encode(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20")
}
private fun JSONObject.nonBlankString(name: String): String? =
optString(name).takeIf { it.isNotBlank() }
@@ -0,0 +1,24 @@
package cloud.molberg.hermesmobile.chat
import cloud.molberg.hermesmobile.domain.DurableChatState
import cloud.molberg.hermesmobile.domain.PendingChatSend
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatStateJsonTest {
@Test
fun durableStateRoundTripsSelectedSessionDraftsAndPendingSends() {
val state = DurableChatState(
selectedSessionId = "session-1",
drafts = mapOf("session-1" to "keep this draft"),
pendingSends = mapOf("session-1" to PendingChatSend("request-1", "send once"))
)
assertEquals(state, ChatStateJson.decode(ChatStateJson.encode(state)))
}
@Test
fun corruptStateFallsBackSafely() {
assertEquals(DurableChatState(), ChatStateJson.decode("not-json"))
}
}
@@ -1,5 +1,7 @@
package cloud.molberg.hermesmobile.chat package cloud.molberg.hermesmobile.chat
import cloud.molberg.hermesmobile.domain.DurableChatState
import cloud.molberg.hermesmobile.domain.PendingChatSend
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
@@ -21,6 +23,43 @@ class FakeChatReducerTest {
assertEquals(ChatRole.User, session.messages[0].role) assertEquals(ChatRole.User, session.messages[0].role)
assertEquals(ChatRole.Assistant, session.messages[1].role) assertEquals(ChatRole.Assistant, session.messages[1].role)
assertTrue(session.messages[1].streaming) assertTrue(session.messages[1].streaming)
assertEquals("m1", session.pendingSendId)
}
@Test
fun restoreAppliesSelectedSessionDraftAndPendingSend() {
val seeded = FakeChatReducer.seed()
val target = seeded.sessions.last()
val restored = FakeChatReducer.restore(
seeded,
DurableChatState(
selectedSessionId = target.id,
drafts = mapOf(target.id to "durable draft"),
pendingSends = mapOf(target.id to PendingChatSend("request-1", "send once"))
)
)
assertEquals(target.id, restored.selectedSessionId)
assertEquals("durable draft", restored.selectedSession!!.draft)
assertTrue(restored.selectedSession!!.busy)
assertEquals("request-1", restored.selectedSession!!.pendingSendId)
}
@Test
fun duplicateSendDoesNotAppendMessagesWhileRequestIsPending() {
val sessionId = "local-1"
val first = FakeChatReducer.send(
FakeChatReducer.newSession(FakeChatReducer.empty(), sessionId),
sessionId,
"send once",
"m1"
)
val duplicate = FakeChatReducer.send(first, sessionId, "send twice", "m2")
assertEquals(first, duplicate)
assertEquals(2, duplicate.selectedSession!!.messages.size)
} }
@Test @Test
@@ -0,0 +1,35 @@
package cloud.molberg.hermesmobile.fake
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 FakeChatStateRepositoryTest {
@Test
fun selectedSessionDraftAndPendingReservationRemainConsistent() {
val repository = FakeChatStateRepository()
repository.selectSession(PROFILE_ID, SESSION_ID)
repository.saveDraft(PROFILE_ID, SESSION_ID, "durable draft")
assertTrue(repository.reserveSend(PROFILE_ID, SESSION_ID, "request-1", "send once"))
assertFalse(repository.reserveSend(PROFILE_ID, SESSION_ID, "request-2", "send twice"))
val pending = repository.load(PROFILE_ID)
assertEquals(SESSION_ID, pending.selectedSessionId)
assertEquals("durable draft", pending.drafts[SESSION_ID])
assertEquals("request-1", pending.pendingSends[SESSION_ID]?.requestId)
repository.finishSend(PROFILE_ID, SESSION_ID, "wrong-request")
assertEquals("request-1", repository.load(PROFILE_ID).pendingSends[SESSION_ID]?.requestId)
repository.finishSend(PROFILE_ID, SESSION_ID, "request-1")
assertNull(repository.load(PROFILE_ID).pendingSends[SESSION_ID])
}
private companion object {
const val PROFILE_ID = "profile-1"
const val SESSION_ID = "session-1"
}
}
@@ -71,4 +71,16 @@ class FakeHermesRepositoryTest {
assertTrue(events.any { it is HermesStreamEvent.ToolUpdated }) assertTrue(events.any { it is HermesStreamEvent.ToolUpdated })
assertTrue(events.any { it is HermesStreamEvent.ArtifactCreated }) assertTrue(events.any { it is HermesStreamEvent.ArtifactCreated })
} }
@Test
fun sessionsCanBeCreatedFetchedAndSearchedThroughRepositoryContract() = runBlocking {
val repository = FakeHermesRepository()
val profile = repository.listProfiles().single()
val created = repository.createSession(profile.id, "Durable release notes")
assertEquals(created, repository.getSession(profile.id, created.id))
assertEquals(listOf(created), repository.searchSessions(profile.id, "release notes"))
assertTrue(repository.searchSessions(profile.id, "missing query").isEmpty())
}
} }
@@ -0,0 +1,65 @@
package cloud.molberg.hermesmobile.session
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class GatewaySessionContractTest {
@Test
fun capabilitiesProvideExactAdvertisedSessionEndpoints() {
val endpoints = GatewaySessionEndpoints.fromCapabilities(
JSONObject(
"""{
"endpoints": {
"session_list": "/api/sessions",
"session_create": "/api/sessions",
"session_get": "/api/sessions/{id}",
"session_delete": "/api/sessions/{id}"
}
}""".trimIndent()
)
)
assertEquals("/api/sessions", endpoints.list)
assertEquals("/api/sessions/{id}", endpoints.get)
}
@Test
fun requestsUseOnlyAdvertisedPathsAndBearerAuthentication() {
val contract = GatewaySessionContract(
GatewaySessionEndpoints(
list = "/api/sessions",
create = "/api/sessions",
get = "/api/sessions/{id}",
delete = "/api/sessions/{id}"
)
)
val list = contract.listRequest(BASE_URL, TOKEN)
val create = contract.createRequest(BASE_URL, TOKEN)
val get = contract.getRequest(BASE_URL, TOKEN, "session / one")
val delete = contract.deleteRequest(BASE_URL, TOKEN, "session-1")
assertEquals("/api/sessions", list.url.encodedPath)
assertEquals("Bearer $TOKEN", list.header("Authorization"))
assertEquals("POST", create.method)
assertEquals(0L, create.body!!.contentLength())
assertEquals("/api/sessions/session%20%2F%20one", get.url.encodedPath)
assertEquals("DELETE", delete.method)
}
@Test
fun absentSessionCapabilityStaysGated() {
val contract = GatewaySessionContract(GatewaySessionEndpoints())
assertThrows(IllegalArgumentException::class.java) {
contract.listRequest(BASE_URL, TOKEN)
}
}
private companion object {
const val BASE_URL = "https://hermes.example.test"
const val TOKEN = "test-token"
}
}
+9
View File
@@ -7,3 +7,12 @@ The documented direct gateway routes currently available to Hermes Mobile (`GET
B2 therefore protects local-first routing by requiring explicit HTTPS URLs, disabling redirects and cleartext traffic, probing `/health` without credentials, and sending a bearer token only for the documented `/v1/models` compatibility check. This confirms TLS reachability and authenticated API compatibility, but it cannot cryptographically prove that separately configured Local and Remote URLs terminate at the same Hermes gateway identity. B2 therefore protects local-first routing by requiring explicit HTTPS URLs, disabling redirects and cleartext traffic, probing `/health` without credentials, and sending a bearer token only for the documented `/v1/models` compatibility check. This confirms TLS reachability and authenticated API compatibility, but it cannot cryptographically prove that separately configured Local and Remote URLs terminate at the same Hermes gateway identity.
Upstream compatibility needed for stronger binding: a documented stable gateway instance ID signed or authenticated consistently across both routes, or an upstream-supported certificate/public-key pin that the Android client can bind to the single logical profile. Until then, Local and Remote pairing remains an explicit user trust decision and diagnostics identify only the active route, not internal URLs. Upstream compatibility needed for stronger binding: a documented stable gateway instance ID signed or authenticated consistently across both routes, or an upstream-supported certificate/public-key pin that the Android client can bind to the single logical profile. Until then, Local and Remote pairing remains an explicit user trust decision and diagnostics identify only the active route, not internal URLs.
## Direct gateway session search contract
The official Hermes Agent API server documentation checked on 2026-07-24 documents authenticated `GET /v1/capabilities` and session endpoint capability entries for list, create, get, update, delete, messages, fork, chat, and chat streaming. It does not document a session-search endpoint, query parameter/body schema, response envelope, or a `session_search` capability entry.
B3 therefore keeps `searchSessions` in the Android repository contract and verifies it with the fake repository, but deliberately provides no direct-gateway HTTP mapping for search. List/create/get/delete request construction is safe only when the live gateway advertises the corresponding endpoint string through `GET /v1/capabilities`.
Upstream evidence needed to unblock B3: official documentation for session search including capability key, HTTP method, path, query/body schema, response envelope, and empty/error semantics, followed by a live compatibility exercise against an upstream Hermes gateway.
Source checked: `https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server/` on 2026-07-24.
+8
View File
@@ -86,3 +86,11 @@ Security and reliability rules:
2. Sessions: keep repository interfaces for list/create/select/continue, but mark route mapping provisional until upstream session docs are verified. 2. Sessions: keep repository interfaces for list/create/select/continue, but mark route mapping provisional until upstream session docs are verified.
3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities. Keep each stream bound to its selected local or remote route. 3. Streaming: support final response and token delta reduction now; gate structured tool/artifact/session events behind server capabilities. Keep each stream bound to its selected local or remote route.
4. Companion removal: after direct gateway chat/session/streaming works end to end, remove legacy companion UI routes and then deprecate install/docs around `apps/companion`. 4. Companion removal: after direct gateway chat/session/streaming works end to end, remove legacy companion UI routes and then deprecate install/docs around `apps/companion`.
## Verified Session Capability Boundary — 2026-07-24
Official upstream API server documentation defines authenticated `GET /v1/capabilities` discovery and advertises session endpoint entries including `session_list`, `session_create`, `session_get`, and `session_delete`. The Android B3 foundation therefore builds requests only from those returned endpoint strings; a missing entry leaves that operation unavailable rather than falling back to an assumed route.
Source checked: `https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server/` on 2026-07-24.
The same official endpoint table does not document session search or a search query schema. Hermes Mobile keeps search in the repository contract and fake implementation for UI/state verification, but does not map search to HTTP until upstream publishes that contract. The exact blocker is tracked in `docs/BLOCKERS.md`.
+1
View File
@@ -16,6 +16,7 @@ Done:
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24. - Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
Remaining for beta: Remaining for beta:
- Complete B3 after upstream documents a session-search endpoint/query schema and a live gateway can verify advertised session CRUD behavior; the independently verifiable durable-state and capability-gated request foundation landed on 2026-07-24.
- Exercise the connection/settings unhappy paths on a real device or emulator; this sandbox cannot start ADB because local control sockets are prohibited. - Exercise the connection/settings unhappy paths on a real device or emulator; this sandbox cannot start ADB because local control sockets are prohibited.
- Exercise a real device or emulator against a running upstream Hermes gateway/API server. - Exercise a real device or emulator against a running upstream Hermes gateway/API server.
- Verify direct gateway behavior end to end beyond B2: chat request/response, session continuity expectations, streamed/final output handling, and failure recovery. - Verify direct gateway behavior end to end beyond B2: chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.