feat: add durable gateway session foundation
This commit is contained in:
@@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
||||
## Unreleased
|
||||
|
||||
### 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 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.
|
||||
@@ -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.
|
||||
|
||||
### 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.
|
||||
- `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.
|
||||
|
||||
@@ -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.
|
||||
- Restore selected session and protect against duplicate sends / lost drafts.
|
||||
- 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**
|
||||
- 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 messages: List<ChatMessageUi>,
|
||||
val draft: String = "",
|
||||
val busy: Boolean = false
|
||||
val busy: Boolean = false,
|
||||
val pendingSendId: String? = null
|
||||
)
|
||||
|
||||
data class FakeChatUiState(
|
||||
@@ -53,4 +54,3 @@ data class FakeChatUiState(
|
||||
val selectedSession: ChatSessionUi?
|
||||
get() = sessions.firstOrNull { it.id == selectedSessionId }
|
||||
}
|
||||
|
||||
|
||||
+27
-4
@@ -1,5 +1,6 @@
|
||||
package cloud.molberg.hermesmobile.chat
|
||||
|
||||
import cloud.molberg.hermesmobile.domain.DurableChatState
|
||||
import cloud.molberg.hermesmobile.fake.FakeHermesRepository
|
||||
|
||||
object FakeChatReducer {
|
||||
@@ -61,6 +62,20 @@ object FakeChatReducer {
|
||||
fun select(state: FakeChatUiState, sessionId: String): FakeChatUiState =
|
||||
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 {
|
||||
val session = ChatSessionUi(
|
||||
id = id,
|
||||
@@ -91,6 +106,8 @@ object FakeChatReducer {
|
||||
fun send(state: FakeChatUiState, sessionId: String, prompt: String, messageId: String): FakeChatUiState {
|
||||
val trimmed = prompt.trim()
|
||||
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"
|
||||
return updateSession(state, sessionId) { session ->
|
||||
val user = ChatMessageUi(
|
||||
@@ -111,7 +128,8 @@ object FakeChatReducer {
|
||||
subtitle = "Hermes is streaming...",
|
||||
messages = session.messages + user + assistant,
|
||||
draft = "",
|
||||
busy = true
|
||||
busy = true,
|
||||
pendingSendId = messageId
|
||||
)
|
||||
}.copy(connectionState = ChatConnectionState.Ready, statusText = "Streaming fake response.")
|
||||
}
|
||||
@@ -135,7 +153,7 @@ object FakeChatReducer {
|
||||
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.")
|
||||
|
||||
fun cancel(state: FakeChatUiState, sessionId: String): FakeChatUiState =
|
||||
@@ -153,14 +171,19 @@ object FakeChatReducer {
|
||||
message
|
||||
}
|
||||
}
|
||||
session.copy(messages = updatedMessages, busy = false, subtitle = "Cancelled")
|
||||
session.copy(messages = updatedMessages, busy = false, pendingSendId = null, subtitle = "Cancelled")
|
||||
}.copy(statusText = "Generation cancelled.")
|
||||
|
||||
fun retry(state: FakeChatUiState, sessionId: String, messageId: String, nextMessageId: String): FakeChatUiState {
|
||||
val session = state.sessions.firstOrNull { it.id == sessionId } ?: return state
|
||||
val prompt = session.messages.firstOrNull { it.id == messageId }?.retryPrompt ?: return state
|
||||
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)
|
||||
}
|
||||
|
||||
+56
-38
@@ -55,6 +55,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -75,37 +76,79 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
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 streamJob by remember { mutableStateOf<Job?>(null) }
|
||||
val streamJobs = remember { mutableMapOf<String, Job>() }
|
||||
|
||||
fun createSession() {
|
||||
val id = "local-${nextId++}"
|
||||
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) {
|
||||
if (prompt.isBlank() || session.busy) return
|
||||
streamJob?.cancel()
|
||||
streamJobs.remove(session.id)?.cancel()
|
||||
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"
|
||||
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)
|
||||
state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nChecking context controls...")
|
||||
delay(450)
|
||||
state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nComposing markdown and code surfaces...")
|
||||
delay(450)
|
||||
state = FakeChatReducer.finishStream(state, session.id, assistantId, prompt)
|
||||
chatStateRepository.finishSend(FAKE_PROFILE_ID, session.id, messageId)
|
||||
streamJobs.remove(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if (resetToken > 0) createSession()
|
||||
}
|
||||
@@ -125,7 +168,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
||||
.width(304.dp)
|
||||
.fillMaxHeight(),
|
||||
onNew = ::createSession,
|
||||
onSelect = { state = FakeChatReducer.select(state, it) },
|
||||
onSelect = ::selectSession,
|
||||
onState = { state = it }
|
||||
)
|
||||
ChatConversationPane(
|
||||
@@ -133,27 +176,10 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
||||
session = selected,
|
||||
modifier = Modifier.weight(1f),
|
||||
onBack = null,
|
||||
onDraft = { id, draft -> state = FakeChatReducer.updateDraft(state, id, draft) },
|
||||
onDraft = ::updateDraft,
|
||||
onSend = ::send,
|
||||
onCancel = ::cancel,
|
||||
onRetry = { sessionId, messageId ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRetry = ::retry,
|
||||
onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) },
|
||||
onContext = { state = FakeChatReducer.updateContext(state, it) },
|
||||
notify = notify
|
||||
@@ -164,7 +190,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onNew = ::createSession,
|
||||
onSelect = { state = FakeChatReducer.select(state, it) },
|
||||
onSelect = ::selectSession,
|
||||
onState = { state = it }
|
||||
)
|
||||
} else {
|
||||
@@ -173,20 +199,10 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
||||
session = selected,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = { state = state.copy(selectedSessionId = null) },
|
||||
onDraft = { id, draft -> state = FakeChatReducer.updateDraft(state, id, draft) },
|
||||
onDraft = ::updateDraft,
|
||||
onSend = ::send,
|
||||
onCancel = ::cancel,
|
||||
onRetry = { sessionId, messageId ->
|
||||
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)
|
||||
}
|
||||
},
|
||||
onRetry = ::retry,
|
||||
onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) },
|
||||
onContext = { state = FakeChatReducer.updateContext(state, it) },
|
||||
notify = notify
|
||||
@@ -195,6 +211,8 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private const val FAKE_PROFILE_ID = "profile-local"
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionRail(
|
||||
state: FakeChatUiState,
|
||||
|
||||
+103
@@ -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())
|
||||
}
|
||||
}
|
||||
+21
@@ -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)
|
||||
}
|
||||
+1
@@ -17,6 +17,7 @@ interface AuthRepository {
|
||||
|
||||
interface HermesSessionRepository {
|
||||
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 createSession(profileId: String, title: String? = null): HermesSession
|
||||
suspend fun deleteSession(profileId: String, sessionId: String)
|
||||
|
||||
+57
@@ -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))
|
||||
}
|
||||
}
|
||||
+9
@@ -96,6 +96,15 @@ class FakeHermesRepository : HermesRepository {
|
||||
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? =
|
||||
sessions[sessionId].takeIf { profiles.containsKey(profileId) }
|
||||
|
||||
|
||||
+73
@@ -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() }
|
||||
+24
@@ -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"))
|
||||
}
|
||||
}
|
||||
+39
@@ -1,5 +1,7 @@
|
||||
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.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -21,6 +23,43 @@ class FakeChatReducerTest {
|
||||
assertEquals(ChatRole.User, session.messages[0].role)
|
||||
assertEquals(ChatRole.Assistant, session.messages[1].role)
|
||||
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
|
||||
|
||||
+35
@@ -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"
|
||||
}
|
||||
}
|
||||
+12
@@ -71,4 +71,16 @@ class FakeHermesRepositoryTest {
|
||||
assertTrue(events.any { it is HermesStreamEvent.ToolUpdated })
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
+65
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
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`.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -16,6 +16,7 @@ Done:
|
||||
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
|
||||
|
||||
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 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.
|
||||
|
||||
Reference in New Issue
Block a user