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
@@ -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 }
}
@@ -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)
}
@@ -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,
@@ -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 {
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)
@@ -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()
}
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) }
@@ -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
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
@@ -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.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"
}
}