diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b7124..2f60e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index bb27758..45e9c04 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -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. diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt index 4ebfdfe..f1c6b8b 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt @@ -40,7 +40,8 @@ data class ChatSessionUi( val subtitle: String, val messages: List, 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 } } - diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt index b838262..95e35da 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt @@ -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) } diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt index 178de78..557aace 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt @@ -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(null) } + val streamJobs = remember { mutableMapOf() } 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() - ?.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()?.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, diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt new file mode 100644 index 0000000..c6b6545 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/StoredChatStateRepository.kt @@ -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()) + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt new file mode 100644 index 0000000..38ec5ca --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/ChatStateRepository.kt @@ -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 = emptyMap(), + val pendingSends: Map = 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) +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesRepository.kt index 3491fde..2286472 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesRepository.kt @@ -17,6 +17,7 @@ interface AuthRepository { interface HermesSessionRepository { suspend fun listSessions(profileId: String): List + suspend fun searchSessions(profileId: String, query: String): List suspend fun getSession(profileId: String, sessionId: String): HermesSession? suspend fun createSession(profileId: String, title: String? = null): HermesSession suspend fun deleteSession(profileId: String, sessionId: String) diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt new file mode 100644 index 0000000..85896f0 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepository.kt @@ -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 = 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)) + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt index fab5c5a..c33ef1b 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt @@ -96,6 +96,15 @@ class FakeHermesRepository : HermesRepository { emptyList() } + override suspend fun searchSessions(profileId: String, query: String): List { + 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) } diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/session/GatewaySessionContract.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/session/GatewaySessionContract.kt new file mode 100644 index 0000000..be519c7 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/session/GatewaySessionContract.kt @@ -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() } diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt new file mode 100644 index 0000000..bf41dd6 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/ChatStateJsonTest.kt @@ -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")) + } +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt index 0dea3f3..d2a213a 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt @@ -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 diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepositoryTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepositoryTest.kt new file mode 100644 index 0000000..4f0dbe8 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeChatStateRepositoryTest.kt @@ -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" + } +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt index 510f830..1084deb 100644 --- a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt @@ -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()) + } } diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/session/GatewaySessionContractTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/session/GatewaySessionContractTest.kt new file mode 100644 index 0000000..82dd489 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/session/GatewaySessionContractTest.kt @@ -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" + } +} diff --git a/docs/BLOCKERS.md b/docs/BLOCKERS.md index 0a946cb..792f5ac 100644 --- a/docs/BLOCKERS.md +++ b/docs/BLOCKERS.md @@ -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. diff --git a/docs/DIRECT_GATEWAY_ARCHITECTURE.md b/docs/DIRECT_GATEWAY_ARCHITECTURE.md index a9609e6..c3db867 100644 --- a/docs/DIRECT_GATEWAY_ARCHITECTURE.md +++ b/docs/DIRECT_GATEWAY_ARCHITECTURE.md @@ -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`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index edb224a..c8b2df2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.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.