feat: add durable gateway session foundation
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user