From 983fb31801a040494c12b8de3720e34f1d7248ce Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 24 Jul 2026 10:23:21 +0000 Subject: [PATCH] feat: add transport domain contracts --- CHANGELOG.md | 5 + PROJECT_PLAN.md | 12 +- .../hermesmobile/chat/FakeChatReducer.kt | 39 +-- .../hermesmobile/domain/HermesDomainModels.kt | 187 +++++++++++ .../hermesmobile/domain/HermesMappers.kt | 279 +++++++++++++++++ .../hermesmobile/domain/HermesRepository.kt | 48 +++ .../domain/HermesStreamReducer.kt | 72 +++++ .../hermesmobile/fake/FakeHermesRepository.kt | 292 ++++++++++++++++++ .../transport/HermesTransportDtos.kt | 188 +++++++++++ .../hermesmobile/domain/HermesMapperTest.kt | 169 ++++++++++ .../domain/HermesStreamReducerTest.kt | 77 +++++ .../fake/FakeHermesRepositoryTest.kt | 65 ++++ 12 files changed, 1391 insertions(+), 42 deletions(-) create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesRepository.kt create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt create mode 100644 apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt create mode 100644 apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesStreamReducerTest.kt create mode 100644 apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e29246..ccd5a31 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 transport DTOs and domain repository contracts for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts. +- Added DTO-to-domain/UI mappers, stream reducer behavior, and a fake Hermes repository with unit coverage for mappings, stream reduction, and repository fixtures. - Added a fake-data Compose chat proving ground with local session rail/list, context controls, keyboard-safe composer, send/cancel/retry, and seeded empty/loading/reconnecting/error states. - Added typed user, assistant, system, error, and streaming chat models; selectable markdown-friendly text, copyable horizontally scrollable code blocks, expandable tool results, and reducer unit tests. - Added a Compose design-system package with color, semantic status, spacing, typography, shape, elevation, and motion tokens. @@ -14,6 +16,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Added `PROJECT_PLAN.md` as the project-controlled milestone plan. ### Changed +- Changed the fake chat seed path to reuse the fake Hermes repository preview state. - Extracted shared Compose cards, rows, buttons, text fields, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`. - Updated the native Appearance screen to describe system light/dark theming and design tokens. - Updated companion `/api/chat` to call `hermes chat -Q --source tool -q `, sanitize CLI banners/help/control output, truncate large mobile replies, and return structured failures. @@ -24,6 +27,8 @@ 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 +- Parent preflight immediately before B1 completion — script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed. +- Parent preflight immediately before B1 completion — `git diff --check` passed. - `git diff --check` — passed - `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` — passed - `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — passed diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 5b39a8b..6fe0119 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -26,12 +26,6 @@ _Foundation milestones complete. Continue with B1._ ## Beta path — upstream-connected MVP -- [ ] **B1 — Transport/domain contracts** - - Define transport DTOs and a repository interface for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts. - - Map DTOs to stable UI models; fake repository powers previews/tests. - - Keep the existing companion as an optional adapter only; do not change upstream Hermes semantics. - - Verification: unit tests for mapping/state reducers; build/typecheck. - - [ ] **B2 — Connection and authentication UX** - Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states. - Store sensitive values using Android-appropriate secure storage; never log secrets. @@ -99,6 +93,12 @@ _Move finished items here with date, commit, and verification. Keep this section - Added seeded empty, loading, reconnecting, and error states plus reducer tests for send, cancel, retry, and tool expansion. - Verification: `git diff --check` passed; `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` passed. +- [x] **B1 — Transport/domain contracts** — 2026-07-24, commit `feat: add transport domain contracts`. + - Added transport DTOs and domain repository contracts for server profiles, authentication, sessions, messages, stream events, tool results, and artifacts. + - Added DTO-to-domain/UI mappers, stream reducer behavior, and a fake Hermes repository for previews and unit tests. + - Kept the companion path as an optional adapter capability without changing upstream Hermes semantics. + - Verification: parent preflight immediately before B1 completion passed: script PASS; Android `:app:assembleDebug` BUILD SUCCESSFUL, 35 tasks total and 6 executed. Parent also verified `git diff --check` passed. + ## Work-loop checklist Before coding: 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 77e4c23..b838262 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,43 +1,10 @@ package cloud.molberg.hermesmobile.chat +import cloud.molberg.hermesmobile.fake.FakeHermesRepository + object FakeChatReducer { fun seed(): FakeChatUiState { - val release = ChatSessionUi( - id = "session-release", - title = "Release checklist", - subtitle = "Streaming fake response with code and tool output", - messages = listOf( - ChatMessageUi( - id = "m-system-release", - role = ChatRole.System, - title = "System", - parts = listOf(MessagePartUi.Text("Thin-client mode. Fake local data only; upstream Hermes remains authoritative.")) - ), - ChatMessageUi( - id = "m-user-release", - role = ChatRole.User, - title = "You", - parts = listOf(MessagePartUi.Text("Draft a compact beta verification checklist with the Android command.")) - ), - ChatMessageUi( - id = "m-assistant-release", - role = ChatRole.Assistant, - title = "Hermes", - parts = listOf( - MessagePartUi.Text("Use the local Android build as the first gate, then exercise the companion flows separately."), - MessagePartUi.Code("bash", "GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug"), - MessagePartUi.Tool( - ToolResultUi( - name = "workspace.inspect", - status = "done", - summary = "Found Android app, companion package, and project docs.", - output = "apps/mobile/android\napps/companion\nPROJECT_PLAN.md\ndocs/ROADMAP.md" - ) - ) - ) - ) - ) - ) + val release = FakeHermesRepository.previewChatState().selectedSession ?: return empty() val reconnecting = ChatSessionUi( id = "session-reconnect", title = "Reconnecting stream", diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt new file mode 100644 index 0000000..8ca4049 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesDomainModels.kt @@ -0,0 +1,187 @@ +package cloud.molberg.hermesmobile.domain + +enum class AuthMode { None, BearerToken, PairingCode } + +enum class AuthState { Anonymous, Pairing, Authenticated, Expired, Unauthorized } + +enum class SessionStatus { Idle, Running, Reconnecting, Completed, Failed, Cancelled } + +enum class MessageRole { User, Assistant, System, Tool, Error } + +enum class MessageStatus { Pending, Streaming, Complete, Failed, Cancelled } + +enum class ToolStatus { Queued, Running, Succeeded, Failed, Cancelled } + +enum class ArtifactKind { File, Image, Log, Patch, Unknown } + +data class ServerProfile( + val id: String, + val name: String, + val baseUrl: String, + val authMode: AuthMode, + val tokenAlias: String?, + val capabilities: ServerCapabilities, + val createdAt: String?, + val updatedAt: String? +) + +data class ServerCapabilities( + val sessions: Boolean, + val streaming: Boolean, + val toolEvents: Boolean, + val artifacts: Boolean, + val companionAdapter: Boolean +) + +data class AuthSession( + val id: String, + val profileId: String, + val state: AuthState, + val principal: String?, + val scopes: List, + val issuedAt: String?, + val expiresAt: String? +) + +data class HermesSession( + val id: String, + val title: String, + val subtitle: String, + val status: SessionStatus, + val model: String?, + val workspace: String?, + val messageCount: Int, + val createdAt: String?, + val updatedAt: String? +) + +data class HermesMessage( + val id: String, + val sessionId: String, + val role: MessageRole, + val parts: List, + val status: MessageStatus, + val createdAt: String? +) + +sealed interface MessagePart { + data class Text(val text: String) : MessagePart + data class Code(val language: String, val code: String) : MessagePart + data class ToolResultRef(val resultId: String) : MessagePart + data class ArtifactRef(val artifactId: String) : MessagePart +} + +data class ToolResult( + val id: String, + val callId: String, + val name: String, + val status: ToolStatus, + val summary: String, + val inputPreview: String?, + val output: String, + val exitCode: Int?, + val startedAt: String?, + val completedAt: String? +) + +data class Artifact( + val id: String, + val sessionId: String, + val name: String, + val kind: ArtifactKind, + val mimeType: String?, + val sizeBytes: Long?, + val uri: String?, + val createdAt: String? +) + +data class SendMessageRequest( + val sessionId: String?, + val prompt: String, + val model: String? = null, + val workspace: String? = null, + val toolsEnabled: Boolean = true +) + +data class SendMessageHandle( + val sessionId: String, + val userMessageId: String, + val assistantMessageId: String +) + +data class AuthCredentials( + val profileId: String, + val bearerToken: String? = null, + val pairingCode: String? = null +) + +sealed interface HermesStreamEvent { + val id: String + val sessionId: String + val timestamp: String? + + data class SessionChanged( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val status: SessionStatus + ) : HermesStreamEvent + + data class MessageStarted( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val message: HermesMessage + ) : HermesStreamEvent + + data class AssistantDelta( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val messageId: String, + val text: String + ) : HermesStreamEvent + + data class MessageCompleted( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val message: HermesMessage + ) : HermesStreamEvent + + data class ToolUpdated( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val result: ToolResult + ) : HermesStreamEvent + + data class ArtifactCreated( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val artifact: Artifact + ) : HermesStreamEvent + + data class Failed( + override val id: String, + override val sessionId: String, + override val timestamp: String?, + val message: String, + val recoverable: Boolean + ) : HermesStreamEvent + + data class Done( + override val id: String, + override val sessionId: String, + override val timestamp: String? + ) : HermesStreamEvent +} + +data class HermesConversationState( + val session: HermesSession, + val messages: List = emptyList(), + val tools: Map = emptyMap(), + val artifacts: Map = emptyMap(), + val error: String? = null +) diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt new file mode 100644 index 0000000..c524153 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesMappers.kt @@ -0,0 +1,279 @@ +package cloud.molberg.hermesmobile.domain + +import cloud.molberg.hermesmobile.chat.ChatConnectionState +import cloud.molberg.hermesmobile.chat.ChatMessageUi +import cloud.molberg.hermesmobile.chat.ChatRole +import cloud.molberg.hermesmobile.chat.ChatSessionUi +import cloud.molberg.hermesmobile.chat.FakeChatUiState +import cloud.molberg.hermesmobile.chat.MessagePartUi +import cloud.molberg.hermesmobile.chat.ToolResultUi +import cloud.molberg.hermesmobile.transport.ArtifactDto +import cloud.molberg.hermesmobile.transport.ArtifactKindDto +import cloud.molberg.hermesmobile.transport.AuthModeDto +import cloud.molberg.hermesmobile.transport.AuthCredentialsDto +import cloud.molberg.hermesmobile.transport.AuthSessionDto +import cloud.molberg.hermesmobile.transport.AuthSessionStateDto +import cloud.molberg.hermesmobile.transport.HermesMessageDto +import cloud.molberg.hermesmobile.transport.HermesSessionDto +import cloud.molberg.hermesmobile.transport.MessagePartDto +import cloud.molberg.hermesmobile.transport.MessageRoleDto +import cloud.molberg.hermesmobile.transport.MessageStatusDto +import cloud.molberg.hermesmobile.transport.SendMessageHandleDto +import cloud.molberg.hermesmobile.transport.SendMessageRequestDto +import cloud.molberg.hermesmobile.transport.ServerCapabilitiesDto +import cloud.molberg.hermesmobile.transport.ServerProfileDto +import cloud.molberg.hermesmobile.transport.SessionStatusDto +import cloud.molberg.hermesmobile.transport.StreamEventDto +import cloud.molberg.hermesmobile.transport.ToolResultDto +import cloud.molberg.hermesmobile.transport.ToolStatusDto + +fun ServerProfileDto.toDomain(): ServerProfile = + baseUrl.trimEnd('/').let { normalizedUrl -> + ServerProfile( + id = id, + name = name.ifBlank { normalizedUrl }, + baseUrl = normalizedUrl, + authMode = auth.mode.toDomain(), + tokenAlias = auth.tokenAlias, + capabilities = capabilities.toDomain(), + createdAt = createdAt, + updatedAt = updatedAt + ) + } + +fun ServerCapabilitiesDto.toDomain(): ServerCapabilities = + ServerCapabilities(sessions, streaming, toolEvents, artifacts, companionAdapter) + +fun AuthSessionDto.toDomain(): AuthSession = + AuthSession(id, profileId, state.toDomain(), principal, scopes, issuedAt, expiresAt) + +fun AuthCredentials.toDto(): AuthCredentialsDto = + AuthCredentialsDto(profileId, bearerToken, pairingCode) + +fun HermesSessionDto.toDomain(): HermesSession { + val stableTitle = title?.trim()?.takeIf { it.isNotBlank() } ?: "Untitled session" + return HermesSession( + id = id, + title = stableTitle, + subtitle = sessionSubtitle(status, updatedAt, messageCount), + status = status.toDomain(), + model = model, + workspace = workspace, + messageCount = messageCount.coerceAtLeast(0), + createdAt = createdAt, + updatedAt = updatedAt + ) +} + +fun HermesMessageDto.toDomain(): HermesMessage = + HermesMessage( + id = id, + sessionId = sessionId, + role = role.toDomain(), + parts = parts.map { it.toDomain() }, + status = status.toDomain(), + createdAt = createdAt + ) + +fun SendMessageRequest.toDto(): SendMessageRequestDto = + SendMessageRequestDto(sessionId, prompt, model, workspace, toolsEnabled) + +fun SendMessageHandleDto.toDomain(): SendMessageHandle = + SendMessageHandle(sessionId, userMessageId, assistantMessageId) + +fun MessagePartDto.toDomain(): MessagePart = + when (this) { + is MessagePartDto.Text -> MessagePart.Text(text) + is MessagePartDto.Code -> MessagePart.Code(language.orEmpty(), code) + is MessagePartDto.ToolResultRef -> MessagePart.ToolResultRef(resultId) + is MessagePartDto.ArtifactRef -> MessagePart.ArtifactRef(artifactId) + } + +fun ToolResultDto.toDomain(): ToolResult = + ToolResult( + id = id, + callId = callId, + name = name, + status = status.toDomain(), + summary = summary ?: status.toReadableLabel(), + inputPreview = inputPreview, + output = output.orEmpty(), + exitCode = exitCode, + startedAt = startedAt, + completedAt = completedAt + ) + +fun ArtifactDto.toDomain(): Artifact = + Artifact(id, sessionId, name, kind.toDomain(), mimeType, sizeBytes, uri, createdAt) + +fun StreamEventDto.toDomain(): HermesStreamEvent = + when (this) { + is StreamEventDto.SessionChanged -> HermesStreamEvent.SessionChanged(id, sessionId, timestamp, status.toDomain()) + is StreamEventDto.MessageStarted -> HermesStreamEvent.MessageStarted(id, sessionId, timestamp, message.toDomain()) + is StreamEventDto.AssistantDelta -> HermesStreamEvent.AssistantDelta(id, sessionId, timestamp, messageId, text) + is StreamEventDto.MessageCompleted -> HermesStreamEvent.MessageCompleted(id, sessionId, timestamp, message.toDomain()) + is StreamEventDto.ToolUpdated -> HermesStreamEvent.ToolUpdated(id, sessionId, timestamp, result.toDomain()) + is StreamEventDto.ArtifactCreated -> HermesStreamEvent.ArtifactCreated(id, sessionId, timestamp, artifact.toDomain()) + is StreamEventDto.Failed -> HermesStreamEvent.Failed(id, sessionId, timestamp, message, recoverable) + is StreamEventDto.Done -> HermesStreamEvent.Done(id, sessionId, timestamp) + } + +fun HermesConversationState.toChatUiState(selectedSessionId: String = session.id): FakeChatUiState = + FakeChatUiState( + sessions = listOf(toChatSessionUi()), + selectedSessionId = selectedSessionId, + connectionState = session.status.toConnectionState(error), + statusText = error ?: session.subtitle + ) + +fun HermesConversationState.toChatSessionUi(): ChatSessionUi = + ChatSessionUi( + id = session.id, + title = session.title, + subtitle = error ?: session.subtitle, + messages = messages.map { it.toChatMessageUi(tools, artifacts) }, + busy = session.status == SessionStatus.Running || session.status == SessionStatus.Reconnecting + ) + +fun HermesMessage.toChatMessageUi( + tools: Map = emptyMap(), + artifacts: Map = emptyMap() +): ChatMessageUi = + ChatMessageUi( + id = id, + role = role.toChatRole(), + title = role.toTitle(), + parts = parts.mapNotNull { it.toChatPartUi(tools, artifacts) }, + streaming = status == MessageStatus.Streaming || status == MessageStatus.Pending + ) + +private fun MessagePart.toChatPartUi( + tools: Map, + artifacts: Map +): MessagePartUi? = + when (this) { + is MessagePart.Text -> MessagePartUi.Text(text) + is MessagePart.Code -> MessagePartUi.Code(language, code) + is MessagePart.ToolResultRef -> tools[resultId]?.toToolResultUi()?.let { MessagePartUi.Tool(it) } + is MessagePart.ArtifactRef -> artifacts[artifactId]?.let { artifact -> + MessagePartUi.Text("Artifact: ${artifact.name}") + } + } + +private fun ToolResult.toToolResultUi(): ToolResultUi = + ToolResultUi(name = name, status = status.toReadableLabel(), summary = summary, output = output) + +private fun SessionStatus.toConnectionState(error: String?): ChatConnectionState = + when { + error != null -> ChatConnectionState.Error + this == SessionStatus.Reconnecting -> ChatConnectionState.Reconnecting + this == SessionStatus.Running -> ChatConnectionState.Ready + else -> ChatConnectionState.Ready + } + +private fun AuthModeDto.toDomain(): AuthMode = + when (this) { + AuthModeDto.None -> AuthMode.None + AuthModeDto.BearerToken -> AuthMode.BearerToken + AuthModeDto.PairingCode -> AuthMode.PairingCode + } + +private fun AuthSessionStateDto.toDomain(): AuthState = + when (this) { + AuthSessionStateDto.Anonymous -> AuthState.Anonymous + AuthSessionStateDto.Pairing -> AuthState.Pairing + AuthSessionStateDto.Authenticated -> AuthState.Authenticated + AuthSessionStateDto.Expired -> AuthState.Expired + AuthSessionStateDto.Unauthorized -> AuthState.Unauthorized + } + +private fun SessionStatusDto.toDomain(): SessionStatus = + when (this) { + SessionStatusDto.Idle -> SessionStatus.Idle + SessionStatusDto.Running -> SessionStatus.Running + SessionStatusDto.Reconnecting -> SessionStatus.Reconnecting + SessionStatusDto.Completed -> SessionStatus.Completed + SessionStatusDto.Failed -> SessionStatus.Failed + SessionStatusDto.Cancelled -> SessionStatus.Cancelled + } + +private fun MessageRoleDto.toDomain(): MessageRole = + when (this) { + MessageRoleDto.User -> MessageRole.User + MessageRoleDto.Assistant -> MessageRole.Assistant + MessageRoleDto.System -> MessageRole.System + MessageRoleDto.Tool -> MessageRole.Tool + MessageRoleDto.Error -> MessageRole.Error + } + +private fun MessageStatusDto.toDomain(): MessageStatus = + when (this) { + MessageStatusDto.Pending -> MessageStatus.Pending + MessageStatusDto.Streaming -> MessageStatus.Streaming + MessageStatusDto.Complete -> MessageStatus.Complete + MessageStatusDto.Failed -> MessageStatus.Failed + MessageStatusDto.Cancelled -> MessageStatus.Cancelled + } + +private fun ToolStatusDto.toDomain(): ToolStatus = + when (this) { + ToolStatusDto.Queued -> ToolStatus.Queued + ToolStatusDto.Running -> ToolStatus.Running + ToolStatusDto.Succeeded -> ToolStatus.Succeeded + ToolStatusDto.Failed -> ToolStatus.Failed + ToolStatusDto.Cancelled -> ToolStatus.Cancelled + } + +private fun ArtifactKindDto.toDomain(): ArtifactKind = + when (this) { + ArtifactKindDto.File -> ArtifactKind.File + ArtifactKindDto.Image -> ArtifactKind.Image + ArtifactKindDto.Log -> ArtifactKind.Log + ArtifactKindDto.Patch -> ArtifactKind.Patch + ArtifactKindDto.Unknown -> ArtifactKind.Unknown + } + +private fun MessageRole.toChatRole(): ChatRole = + when (this) { + MessageRole.User -> ChatRole.User + MessageRole.Assistant, MessageRole.Tool -> ChatRole.Assistant + MessageRole.System -> ChatRole.System + MessageRole.Error -> ChatRole.Error + } + +private fun MessageRole.toTitle(): String = + when (this) { + MessageRole.User -> "You" + MessageRole.Assistant -> "Hermes" + MessageRole.System -> "System" + MessageRole.Tool -> "Tool" + MessageRole.Error -> "Error" + } + +private fun ToolStatus.toReadableLabel(): String = + when (this) { + ToolStatus.Queued -> "queued" + ToolStatus.Running -> "running" + ToolStatus.Succeeded -> "done" + ToolStatus.Failed -> "failed" + ToolStatus.Cancelled -> "cancelled" + } + +private fun ToolStatusDto.toReadableLabel(): String = + when (this) { + ToolStatusDto.Queued -> "queued" + ToolStatusDto.Running -> "running" + ToolStatusDto.Succeeded -> "done" + ToolStatusDto.Failed -> "failed" + ToolStatusDto.Cancelled -> "cancelled" + } + +private fun sessionSubtitle(status: SessionStatusDto, updatedAt: String?, messageCount: Int): String = + when (status) { + SessionStatusDto.Running -> "Hermes is streaming..." + SessionStatusDto.Reconnecting -> "Reconnecting..." + SessionStatusDto.Failed -> "Failed" + SessionStatusDto.Cancelled -> "Cancelled" + SessionStatusDto.Completed -> updatedAt?.let { "Updated $it" } ?: "$messageCount messages" + SessionStatusDto.Idle -> updatedAt?.let { "Updated $it" } ?: "Ready" + } 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 new file mode 100644 index 0000000..3491fde --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesRepository.kt @@ -0,0 +1,48 @@ +package cloud.molberg.hermesmobile.domain + +import kotlinx.coroutines.flow.Flow + +interface ServerProfileRepository { + suspend fun listProfiles(): List + suspend fun getProfile(profileId: String): ServerProfile? + suspend fun saveProfile(profile: ServerProfile): ServerProfile + suspend fun removeProfile(profileId: String) +} + +interface AuthRepository { + suspend fun authenticate(credentials: AuthCredentials): AuthSession + suspend fun currentSession(profileId: String): AuthSession? + suspend fun logout(profileId: String) +} + +interface HermesSessionRepository { + suspend fun listSessions(profileId: 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) +} + +interface HermesMessageRepository { + suspend fun listMessages(profileId: String, sessionId: String): List + suspend fun sendMessage(profileId: String, request: SendMessageRequest): SendMessageHandle + fun streamEvents(profileId: String, sessionId: String): Flow + suspend fun cancel(profileId: String, sessionId: String) +} + +interface ToolResultRepository { + suspend fun listToolResults(profileId: String, sessionId: String): List + suspend fun getToolResult(profileId: String, resultId: String): ToolResult? +} + +interface ArtifactRepository { + suspend fun listArtifacts(profileId: String, sessionId: String): List + suspend fun getArtifact(profileId: String, artifactId: String): Artifact? +} + +interface HermesRepository : + ServerProfileRepository, + AuthRepository, + HermesSessionRepository, + HermesMessageRepository, + ToolResultRepository, + ArtifactRepository diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt new file mode 100644 index 0000000..7dd4cd6 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/domain/HermesStreamReducer.kt @@ -0,0 +1,72 @@ +package cloud.molberg.hermesmobile.domain + +object HermesStreamReducer { + fun reduce(state: HermesConversationState, event: HermesStreamEvent): HermesConversationState = + when (event) { + is HermesStreamEvent.SessionChanged -> state.copy( + session = state.session.copy(status = event.status, subtitle = event.status.toSubtitle()), + error = null + ) + + is HermesStreamEvent.MessageStarted -> state.copy( + messages = state.messages.upsert(event.message), + session = state.session.copy(status = SessionStatus.Running, subtitle = "Hermes is streaming..."), + error = null + ) + + is HermesStreamEvent.AssistantDelta -> state.copy( + messages = state.messages.map { message -> + if (message.id == event.messageId) message.appendText(event.text).copy(status = MessageStatus.Streaming) else message + }, + error = null + ) + + is HermesStreamEvent.MessageCompleted -> state.copy( + messages = state.messages.upsert(event.message.copy(status = MessageStatus.Complete)), + session = state.session.copy(status = SessionStatus.Completed, subtitle = "Complete"), + error = null + ) + + is HermesStreamEvent.ToolUpdated -> state.copy( + tools = state.tools + (event.result.id to event.result), + error = null + ) + + is HermesStreamEvent.ArtifactCreated -> state.copy( + artifacts = state.artifacts + (event.artifact.id to event.artifact), + error = null + ) + + is HermesStreamEvent.Failed -> state.copy( + session = state.session.copy(status = SessionStatus.Failed, subtitle = "Failed"), + error = event.message + ) + + is HermesStreamEvent.Done -> state.copy( + session = state.session.copy(status = SessionStatus.Completed, subtitle = "Complete"), + messages = state.messages.map { if (it.status == MessageStatus.Streaming) it.copy(status = MessageStatus.Complete) else it } + ) + } + + private fun List.upsert(message: HermesMessage): List = + if (any { it.id == message.id }) map { if (it.id == message.id) message else it } else this + message + + private fun HermesMessage.appendText(delta: String): HermesMessage { + val lastTextIndex = parts.indexOfLast { it is MessagePart.Text } + if (lastTextIndex < 0) return copy(parts = parts + MessagePart.Text(delta)) + val updated = parts.mapIndexed { index, part -> + if (index == lastTextIndex && part is MessagePart.Text) part.copy(text = part.text + delta) else part + } + return copy(parts = updated) + } + + private fun SessionStatus.toSubtitle(): String = + when (this) { + SessionStatus.Idle -> "Ready" + SessionStatus.Running -> "Hermes is streaming..." + SessionStatus.Reconnecting -> "Reconnecting..." + SessionStatus.Completed -> "Complete" + SessionStatus.Failed -> "Failed" + SessionStatus.Cancelled -> "Cancelled" + } +} 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 new file mode 100644 index 0000000..2862c0b --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/fake/FakeHermesRepository.kt @@ -0,0 +1,292 @@ +package cloud.molberg.hermesmobile.fake + +import cloud.molberg.hermesmobile.chat.FakeChatUiState +import cloud.molberg.hermesmobile.domain.Artifact +import cloud.molberg.hermesmobile.domain.AuthCredentials +import cloud.molberg.hermesmobile.domain.AuthSession +import cloud.molberg.hermesmobile.domain.AuthState +import cloud.molberg.hermesmobile.domain.HermesConversationState +import cloud.molberg.hermesmobile.domain.HermesMessage +import cloud.molberg.hermesmobile.domain.HermesRepository +import cloud.molberg.hermesmobile.domain.HermesSession +import cloud.molberg.hermesmobile.domain.HermesStreamEvent +import cloud.molberg.hermesmobile.domain.HermesStreamReducer +import cloud.molberg.hermesmobile.domain.MessagePart +import cloud.molberg.hermesmobile.domain.MessageRole +import cloud.molberg.hermesmobile.domain.MessageStatus +import cloud.molberg.hermesmobile.domain.SendMessageHandle +import cloud.molberg.hermesmobile.domain.SendMessageRequest +import cloud.molberg.hermesmobile.domain.ServerProfile +import cloud.molberg.hermesmobile.domain.SessionStatus +import cloud.molberg.hermesmobile.domain.ToolResult +import cloud.molberg.hermesmobile.domain.toChatUiState +import cloud.molberg.hermesmobile.domain.toDomain +import cloud.molberg.hermesmobile.transport.ArtifactDto +import cloud.molberg.hermesmobile.transport.ArtifactKindDto +import cloud.molberg.hermesmobile.transport.AuthConfigDto +import cloud.molberg.hermesmobile.transport.AuthModeDto +import cloud.molberg.hermesmobile.transport.AuthSessionDto +import cloud.molberg.hermesmobile.transport.AuthSessionStateDto +import cloud.molberg.hermesmobile.transport.HermesMessageDto +import cloud.molberg.hermesmobile.transport.HermesSessionDto +import cloud.molberg.hermesmobile.transport.MessagePartDto +import cloud.molberg.hermesmobile.transport.MessageRoleDto +import cloud.molberg.hermesmobile.transport.MessageStatusDto +import cloud.molberg.hermesmobile.transport.ServerCapabilitiesDto +import cloud.molberg.hermesmobile.transport.ServerProfileDto +import cloud.molberg.hermesmobile.transport.SessionStatusDto +import cloud.molberg.hermesmobile.transport.StreamEventDto +import cloud.molberg.hermesmobile.transport.ToolResultDto +import cloud.molberg.hermesmobile.transport.ToolStatusDto +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow + +class FakeHermesRepository : HermesRepository { + private val profiles = mutableMapOf(FakeHermesFixtures.profile.id to FakeHermesFixtures.profile.toDomain()) + private val authSessions = mutableMapOf(FakeHermesFixtures.auth.profileId to FakeHermesFixtures.auth.toDomain()) + private val sessions = FakeHermesFixtures.sessions.associate { it.id to it.toDomain() }.toMutableMap() + private val messages = FakeHermesFixtures.messages.groupBy { it.sessionId } + .mapValues { entry -> entry.value.map { it.toDomain() }.toMutableList() } + .toMutableMap() + private val tools = FakeHermesFixtures.tools.associate { it.id to it.toDomain() }.toMutableMap() + private val artifacts = FakeHermesFixtures.artifacts.associate { it.id to it.toDomain() }.toMutableMap() + + override suspend fun listProfiles(): List = profiles.values.sortedBy { it.name } + + override suspend fun getProfile(profileId: String): ServerProfile? = + profiles[profileId] + + override suspend fun saveProfile(profile: ServerProfile): ServerProfile { + profiles[profile.id] = profile + return profile + } + + override suspend fun removeProfile(profileId: String) { + profiles.remove(profileId) + authSessions.remove(profileId) + } + + override suspend fun authenticate(credentials: AuthCredentials): AuthSession { + val existing = authSessions[credentials.profileId] + if (existing != null) return existing + val session = AuthSession( + id = "auth-${credentials.profileId}", + profileId = credentials.profileId, + state = if (profiles.containsKey(credentials.profileId)) AuthState.Authenticated else AuthState.Unauthorized, + principal = profiles[credentials.profileId]?.name, + scopes = listOf("chat", "sessions", "artifacts"), + issuedAt = null, + expiresAt = null + ) + authSessions[credentials.profileId] = session + return session + } + + override suspend fun currentSession(profileId: String): AuthSession? = + authSessions[profileId] + + override suspend fun logout(profileId: String) { + authSessions.remove(profileId) + } + + override suspend fun listSessions(profileId: String): List = + if (profiles.containsKey(profileId)) { + sessions.values.sortedByDescending { it.updatedAt.orEmpty() } + } else { + emptyList() + } + + override suspend fun getSession(profileId: String, sessionId: String): HermesSession? = + sessions[sessionId].takeIf { profiles.containsKey(profileId) } + + override suspend fun createSession(profileId: String, title: String?): HermesSession { + val activeProfile = profiles[profileId] ?: error("Unknown fake profile: $profileId") + val id = "fake-${sessions.size + 1}" + val session = HermesSession( + id = id, + title = title ?: "New chat", + subtitle = "Ready", + status = SessionStatus.Idle, + model = null, + workspace = activeProfile.baseUrl, + messageCount = 0, + createdAt = null, + updatedAt = null + ) + sessions[id] = session + messages[id] = mutableListOf() + return session + } + + override suspend fun deleteSession(profileId: String, sessionId: String) { + sessions.remove(sessionId) + messages.remove(sessionId) + } + + override suspend fun listMessages(profileId: String, sessionId: String): List = + if (profiles.containsKey(profileId)) messages[sessionId].orEmpty() else emptyList() + + override suspend fun sendMessage(profileId: String, request: SendMessageRequest): SendMessageHandle { + val title = request.prompt.lineSequence().firstOrNull()?.take(48)?.ifBlank { null } + val session = request.sessionId?.let { sessions[it] } ?: createSession(profileId, title) + val count = messages[session.id].orEmpty().size + 1 + val userId = "${session.id}-u$count" + val assistantId = "${session.id}-a$count" + val user = HermesMessage( + id = userId, + sessionId = session.id, + role = MessageRole.User, + parts = listOf(MessagePart.Text(request.prompt)), + status = MessageStatus.Complete, + createdAt = null + ) + val assistant = HermesMessage( + id = assistantId, + sessionId = session.id, + role = MessageRole.Assistant, + parts = listOf(MessagePart.Text("")), + status = MessageStatus.Streaming, + createdAt = null + ) + messages.getOrPut(session.id) { mutableListOf() }.addAll(listOf(user, assistant)) + sessions[session.id] = session.copy(status = SessionStatus.Running, subtitle = "Hermes is streaming...", messageCount = count + 1) + return SendMessageHandle(session.id, userId, assistantId) + } + + override fun streamEvents(profileId: String, sessionId: String): Flow = + if (profiles.containsKey(profileId)) { + FakeHermesFixtures.stream(sessionId).map { it.toDomain() }.asFlow() + } else { + emptyList().asFlow() + } + + override suspend fun cancel(profileId: String, sessionId: String) { + sessions[sessionId]?.let { sessions[sessionId] = it.copy(status = SessionStatus.Cancelled, subtitle = "Cancelled") } + } + + override suspend fun listToolResults(profileId: String, sessionId: String): List = + if (profiles.containsKey(profileId)) { + tools.values.filter { result -> + messages[sessionId].orEmpty().any { message -> + message.parts.any { part -> part is MessagePart.ToolResultRef && part.resultId == result.id } + } + } + } else { + emptyList() + } + + override suspend fun getToolResult(profileId: String, resultId: String): ToolResult? = + tools[resultId].takeIf { profiles.containsKey(profileId) } + + override suspend fun listArtifacts(profileId: String, sessionId: String): List = + if (profiles.containsKey(profileId)) artifacts.values.filter { it.sessionId == sessionId } else emptyList() + + override suspend fun getArtifact(profileId: String, artifactId: String): Artifact? = + artifacts[artifactId].takeIf { profiles.containsKey(profileId) } + + companion object { + fun previewConversation(): HermesConversationState = + FakeHermesFixtures.conversation() + + fun previewChatState(): FakeChatUiState = + previewConversation().toChatUiState() + } +} + +private object FakeHermesFixtures { + val profile = ServerProfileDto( + id = "profile-local", + name = "Local Hermes", + baseUrl = "http://10.0.2.2:8787/", + auth = AuthConfigDto(mode = AuthModeDto.BearerToken, tokenAlias = "local-dev-key"), + capabilities = ServerCapabilitiesDto(companionAdapter = true) + ) + + val auth = AuthSessionDto( + id = "auth-local", + profileId = profile.id, + state = AuthSessionStateDto.Authenticated, + principal = "local developer", + scopes = listOf("chat", "sessions", "artifacts") + ) + + val sessions = listOf( + HermesSessionDto( + id = "session-release", + title = "Release checklist", + status = SessionStatusDto.Completed, + model = "Hermes default", + workspace = "/root/hermes-mobile", + messageCount = 3, + updatedAt = "2026-07-24T00:00:00Z" + ) + ) + + val tools = listOf( + ToolResultDto( + id = "tool-workspace", + callId = "call-workspace", + name = "workspace.inspect", + status = ToolStatusDto.Succeeded, + summary = "Found Android app, companion package, and project docs.", + output = "apps/mobile/android\napps/companion\nPROJECT_PLAN.md\ndocs/ROADMAP.md" + ) + ) + + val artifacts = listOf( + ArtifactDto( + id = "artifact-checklist", + sessionId = "session-release", + name = "beta-checklist.md", + kind = ArtifactKindDto.File, + mimeType = "text/markdown", + sizeBytes = 2048 + ) + ) + + val messages = listOf( + HermesMessageDto( + id = "m-system-release", + sessionId = "session-release", + role = MessageRoleDto.System, + parts = listOf(MessagePartDto.Text("Thin-client mode. Fake local data only; upstream Hermes remains authoritative.")) + ), + HermesMessageDto( + id = "m-user-release", + sessionId = "session-release", + role = MessageRoleDto.User, + parts = listOf(MessagePartDto.Text("Draft a compact beta verification checklist with the Android command.")) + ), + HermesMessageDto( + id = "m-assistant-release", + sessionId = "session-release", + role = MessageRoleDto.Assistant, + parts = listOf( + MessagePartDto.Text("Use the local Android build as the first gate, then exercise the companion flows separately."), + MessagePartDto.Code("bash", "GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug"), + MessagePartDto.ToolResultRef("tool-workspace"), + MessagePartDto.ArtifactRef("artifact-checklist") + ), + status = MessageStatusDto.Complete + ) + ) + + fun stream(sessionId: String): List = + listOf( + StreamEventDto.SessionChanged("ev-start", sessionId, status = SessionStatusDto.Running), + StreamEventDto.AssistantDelta("ev-delta-1", sessionId, messageId = "m-assistant-release", text = "\nChecking context controls..."), + StreamEventDto.ToolUpdated("ev-tool-1", sessionId, result = tools.single()), + StreamEventDto.ArtifactCreated("ev-artifact-1", sessionId, artifact = artifacts.single()), + StreamEventDto.Done("ev-done", sessionId) + ) + + fun conversation(): HermesConversationState = + HermesConversationState( + session = sessions.single().toDomain(), + messages = messages.map { it.toDomain() }, + tools = tools.associate { it.id to it.toDomain() }, + artifacts = artifacts.associate { it.id to it.toDomain() } + ).let { state -> + stream(state.session.id).fold(state) { current, event -> HermesStreamReducer.reduce(current, event.toDomain()) } + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt new file mode 100644 index 0000000..0c0994e --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/transport/HermesTransportDtos.kt @@ -0,0 +1,188 @@ +package cloud.molberg.hermesmobile.transport + +enum class AuthModeDto { None, BearerToken, PairingCode } + +enum class AuthSessionStateDto { Anonymous, Pairing, Authenticated, Expired, Unauthorized } + +enum class SessionStatusDto { Idle, Running, Reconnecting, Completed, Failed, Cancelled } + +enum class MessageRoleDto { User, Assistant, System, Tool, Error } + +enum class MessageStatusDto { Pending, Streaming, Complete, Failed, Cancelled } + +enum class ToolStatusDto { Queued, Running, Succeeded, Failed, Cancelled } + +enum class ArtifactKindDto { File, Image, Log, Patch, Unknown } + +data class ServerProfileDto( + val id: String, + val name: String, + val baseUrl: String, + val auth: AuthConfigDto = AuthConfigDto(), + val capabilities: ServerCapabilitiesDto = ServerCapabilitiesDto(), + val createdAt: String? = null, + val updatedAt: String? = null +) + +data class AuthConfigDto( + val mode: AuthModeDto = AuthModeDto.None, + val tokenAlias: String? = null, + val pairingCode: String? = null +) + +data class ServerCapabilitiesDto( + val sessions: Boolean = true, + val streaming: Boolean = true, + val toolEvents: Boolean = true, + val artifacts: Boolean = true, + val companionAdapter: Boolean = false +) + +data class AuthSessionDto( + val id: String, + val profileId: String, + val state: AuthSessionStateDto, + val principal: String? = null, + val scopes: List = emptyList(), + val issuedAt: String? = null, + val expiresAt: String? = null +) + +data class AuthCredentialsDto( + val profileId: String, + val bearerToken: String? = null, + val pairingCode: String? = null +) + +data class HermesSessionDto( + val id: String, + val title: String? = null, + val status: SessionStatusDto = SessionStatusDto.Idle, + val model: String? = null, + val workspace: String? = null, + val messageCount: Int = 0, + val createdAt: String? = null, + val updatedAt: String? = null +) + +data class CreateSessionRequestDto( + val profileId: String, + val title: String? = null +) + +data class HermesMessageDto( + val id: String, + val sessionId: String, + val role: MessageRoleDto, + val parts: List, + val status: MessageStatusDto = MessageStatusDto.Complete, + val createdAt: String? = null +) + +sealed interface MessagePartDto { + data class Text(val text: String) : MessagePartDto + data class Code(val language: String? = null, val code: String) : MessagePartDto + data class ToolResultRef(val resultId: String) : MessagePartDto + data class ArtifactRef(val artifactId: String) : MessagePartDto +} + +data class SendMessageRequestDto( + val sessionId: String? = null, + val prompt: String, + val model: String? = null, + val workspace: String? = null, + val toolsEnabled: Boolean = true +) + +data class SendMessageHandleDto( + val sessionId: String, + val userMessageId: String, + val assistantMessageId: String +) + +data class ToolResultDto( + val id: String, + val callId: String, + val name: String, + val status: ToolStatusDto, + val summary: String? = null, + val inputPreview: String? = null, + val output: String? = null, + val exitCode: Int? = null, + val startedAt: String? = null, + val completedAt: String? = null +) + +data class ArtifactDto( + val id: String, + val sessionId: String, + val name: String, + val kind: ArtifactKindDto = ArtifactKindDto.Unknown, + val mimeType: String? = null, + val sizeBytes: Long? = null, + val uri: String? = null, + val createdAt: String? = null +) + +sealed interface StreamEventDto { + val id: String + val sessionId: String + val timestamp: String? + + data class SessionChanged( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val status: SessionStatusDto + ) : StreamEventDto + + data class MessageStarted( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val message: HermesMessageDto + ) : StreamEventDto + + data class AssistantDelta( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val messageId: String, + val text: String + ) : StreamEventDto + + data class MessageCompleted( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val message: HermesMessageDto + ) : StreamEventDto + + data class ToolUpdated( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val result: ToolResultDto + ) : StreamEventDto + + data class ArtifactCreated( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val artifact: ArtifactDto + ) : StreamEventDto + + data class Failed( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null, + val message: String, + val recoverable: Boolean = true + ) : StreamEventDto + + data class Done( + override val id: String, + override val sessionId: String, + override val timestamp: String? = null + ) : StreamEventDto +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt new file mode 100644 index 0000000..7b47653 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesMapperTest.kt @@ -0,0 +1,169 @@ +package cloud.molberg.hermesmobile.domain + +import cloud.molberg.hermesmobile.chat.ChatRole +import cloud.molberg.hermesmobile.chat.MessagePartUi +import cloud.molberg.hermesmobile.transport.ArtifactDto +import cloud.molberg.hermesmobile.transport.ArtifactKindDto +import cloud.molberg.hermesmobile.transport.AuthConfigDto +import cloud.molberg.hermesmobile.transport.AuthModeDto +import cloud.molberg.hermesmobile.transport.HermesMessageDto +import cloud.molberg.hermesmobile.transport.HermesSessionDto +import cloud.molberg.hermesmobile.transport.MessagePartDto +import cloud.molberg.hermesmobile.transport.MessageRoleDto +import cloud.molberg.hermesmobile.transport.MessageStatusDto +import cloud.molberg.hermesmobile.transport.SendMessageHandleDto +import cloud.molberg.hermesmobile.transport.ServerCapabilitiesDto +import cloud.molberg.hermesmobile.transport.ServerProfileDto +import cloud.molberg.hermesmobile.transport.SessionStatusDto +import cloud.molberg.hermesmobile.transport.StreamEventDto +import cloud.molberg.hermesmobile.transport.ToolResultDto +import cloud.molberg.hermesmobile.transport.ToolStatusDto +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HermesMapperTest { + @Test + fun serverProfileTrimsUrlAndPreservesOptionalCompanionCapability() { + val profile = ServerProfileDto( + id = "p1", + name = "", + baseUrl = "https://hermes.example.test/", + auth = AuthConfigDto(AuthModeDto.BearerToken, tokenAlias = "prod"), + capabilities = ServerCapabilitiesDto(companionAdapter = true) + ).toDomain() + + assertEquals("https://hermes.example.test", profile.baseUrl) + assertEquals("https://hermes.example.test", profile.name) + assertEquals(AuthMode.BearerToken, profile.authMode) + assertTrue(profile.capabilities.companionAdapter) + } + + @Test + fun sessionAndMessageDtosMapToStableUiModels() { + val tool = ToolResultDto( + id = "tool-1", + callId = "call-1", + name = "workspace.inspect", + status = ToolStatusDto.Succeeded, + output = "README.md" + ).toDomain() + val session = HermesSessionDto( + id = "s1", + title = null, + status = SessionStatusDto.Running, + messageCount = -4 + ).toDomain() + val message = HermesMessageDto( + id = "m1", + sessionId = "s1", + role = MessageRoleDto.Assistant, + status = MessageStatusDto.Streaming, + parts = listOf( + MessagePartDto.Text("hello"), + MessagePartDto.Code(null, "val x = 1"), + MessagePartDto.ToolResultRef("tool-1") + ) + ).toDomain() + + val uiState = HermesConversationState(session, listOf(message), mapOf(tool.id to tool)).toChatUiState() + val uiMessage = uiState.selectedSession!!.messages.single() + + assertEquals("Untitled session", uiState.selectedSession!!.title) + assertEquals(0, session.messageCount) + assertEquals(ChatRole.Assistant, uiMessage.role) + assertTrue(uiMessage.streaming) + assertEquals("", (uiMessage.parts[1] as MessagePartUi.Code).language) + assertEquals("done", (uiMessage.parts[2] as MessagePartUi.Tool).result.status) + } + + @Test + fun missingToolReferencesAreNotRenderedAsBrokenUiParts() { + val message = HermesMessage( + id = "m1", + sessionId = "s1", + role = MessageRole.Assistant, + parts = listOf(MessagePart.ToolResultRef("missing")), + status = MessageStatus.Complete, + createdAt = null + ) + + assertFalse(message.toChatMessageUi().parts.any { it is MessagePartUi.Tool }) + } + + @Test + fun artifactReferencesRenderStableArtifactNameWhenAvailable() { + val artifact = ArtifactDto( + id = "artifact-1", + sessionId = "s1", + name = "summary.md", + kind = ArtifactKindDto.File + ).toDomain() + val message = HermesMessage( + id = "m1", + sessionId = "s1", + role = MessageRole.Assistant, + parts = listOf(MessagePart.ArtifactRef(artifact.id)), + status = MessageStatus.Complete, + createdAt = null + ) + + val part = message.toChatMessageUi(artifacts = mapOf(artifact.id to artifact)).parts.single() + + assertEquals(MessagePartUi.Text("Artifact: summary.md"), part) + } + + @Test + fun streamEventDtosMapToStableDomainEvents() { + val dto = StreamEventDto.ToolUpdated( + id = "event-1", + sessionId = "s1", + timestamp = "2026-07-24T00:00:00Z", + result = ToolResultDto( + id = "tool-1", + callId = "call-1", + name = "workspace.inspect", + status = ToolStatusDto.Running + ) + ) + + val event = dto.toDomain() as HermesStreamEvent.ToolUpdated + + assertEquals("event-1", event.id) + assertEquals("s1", event.sessionId) + assertEquals(ToolStatus.Running, event.result.status) + assertEquals("running", event.result.summary) + } + + @Test + fun authAndSendRequestsMapToTransportWithoutDroppingOptionalValues() { + val credentials = AuthCredentials( + profileId = "profile-1", + bearerToken = "secret", + pairingCode = "123456" + ).toDto() + val request = SendMessageRequest( + sessionId = "session-1", + prompt = "hello", + model = "hermes-large", + workspace = "/workspace", + toolsEnabled = false + ).toDto() + val handle = SendMessageHandleDto( + sessionId = "session-1", + userMessageId = "user-1", + assistantMessageId = "assistant-1" + ).toDomain() + + assertEquals("profile-1", credentials.profileId) + assertEquals("secret", credentials.bearerToken) + assertEquals("123456", credentials.pairingCode) + assertEquals("session-1", request.sessionId) + assertEquals("hello", request.prompt) + assertEquals("hermes-large", request.model) + assertEquals("/workspace", request.workspace) + assertFalse(request.toolsEnabled) + assertEquals("assistant-1", handle.assistantMessageId) + } +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesStreamReducerTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesStreamReducerTest.kt new file mode 100644 index 0000000..116c34c --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/domain/HermesStreamReducerTest.kt @@ -0,0 +1,77 @@ +package cloud.molberg.hermesmobile.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HermesStreamReducerTest { + @Test + fun assistantDeltaAppendsToExistingTextPartAndKeepsStreaming() { + val start = baseState( + messages = listOf( + HermesMessage( + id = "a1", + sessionId = "s1", + role = MessageRole.Assistant, + parts = listOf(MessagePart.Text("Hello")), + status = MessageStatus.Pending, + createdAt = null + ) + ) + ) + + val result = HermesStreamReducer.reduce( + start, + HermesStreamEvent.AssistantDelta("e1", "s1", null, "a1", ", world") + ) + + val text = result.messages.single().parts.single() as MessagePart.Text + assertEquals("Hello, world", text.text) + assertEquals(MessageStatus.Streaming, result.messages.single().status) + assertNull(result.error) + } + + @Test + fun toolAndArtifactEventsAreStoredById() { + val tool = ToolResult("t1", "call-1", "workspace.inspect", ToolStatus.Running, "Running", null, "", null, null, null) + val artifact = Artifact("a1", "s1", "output.log", ArtifactKind.Log, "text/plain", 12L, null, null) + + val withTool = HermesStreamReducer.reduce(baseState(), HermesStreamEvent.ToolUpdated("e1", "s1", null, tool)) + val withArtifact = HermesStreamReducer.reduce(withTool, HermesStreamEvent.ArtifactCreated("e2", "s1", null, artifact)) + + assertEquals(tool, withArtifact.tools["t1"]) + assertEquals(artifact, withArtifact.artifacts["a1"]) + } + + @Test + fun failureMarksSessionFailedAndKeepsRecoverableErrorText() { + val result = HermesStreamReducer.reduce( + baseState(), + HermesStreamEvent.Failed("e1", "s1", null, "Stream disconnected", recoverable = true) + ) + + assertEquals(SessionStatus.Failed, result.session.status) + assertEquals("Stream disconnected", result.error) + } + + @Test + fun doneCompletesStreamingMessages() { + val start = baseState( + messages = listOf( + HermesMessage("a1", "s1", MessageRole.Assistant, listOf(MessagePart.Text("done")), MessageStatus.Streaming, null) + ) + ) + + val result = HermesStreamReducer.reduce(start, HermesStreamEvent.Done("e1", "s1", null)) + + assertEquals(SessionStatus.Completed, result.session.status) + assertTrue(result.messages.all { it.status == MessageStatus.Complete }) + } + + private fun baseState(messages: List = emptyList()): HermesConversationState = + HermesConversationState( + session = HermesSession("s1", "Test", "Ready", SessionStatus.Idle, null, null, messages.size, null, null), + messages = messages + ) +} 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 new file mode 100644 index 0000000..098eb43 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/fake/FakeHermesRepositoryTest.kt @@ -0,0 +1,65 @@ +package cloud.molberg.hermesmobile.fake + +import cloud.molberg.hermesmobile.domain.AuthMode +import cloud.molberg.hermesmobile.domain.AuthState +import cloud.molberg.hermesmobile.domain.HermesStreamEvent +import cloud.molberg.hermesmobile.domain.ServerCapabilities +import cloud.molberg.hermesmobile.domain.ServerProfile +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeHermesRepositoryTest { + @Test + fun profilesCanBeSavedListedAndRemoved() = runBlocking { + val repository = FakeHermesRepository() + val profile = ServerProfile( + id = "profile-test", + name = "Test Hermes", + baseUrl = "https://hermes.example.test", + authMode = AuthMode.BearerToken, + tokenAlias = "test-token", + capabilities = ServerCapabilities( + sessions = true, + streaming = true, + toolEvents = true, + artifacts = true, + companionAdapter = false + ), + createdAt = null, + updatedAt = null + ) + + repository.saveProfile(profile) + + assertEquals(profile, repository.getProfile(profile.id)) + assertTrue(repository.listProfiles().any { it.id == profile.id }) + + repository.removeProfile(profile.id) + + assertNull(repository.getProfile(profile.id)) + } + + @Test + fun previewRepositoryProvidesSessionsMessagesToolsArtifactsAndStreamEvents() = runBlocking { + val repository = FakeHermesRepository() + val profile = repository.listProfiles().single() + + val auth = repository.currentSession(profile.id) + val session = repository.listSessions(profile.id).single() + val messages = repository.listMessages(profile.id, session.id) + val tools = repository.listToolResults(profile.id, session.id) + val artifacts = repository.listArtifacts(profile.id, session.id) + val events = repository.streamEvents(profile.id, session.id).toList() + + assertEquals(AuthState.Authenticated, auth!!.state) + assertEquals(3, messages.size) + assertEquals("workspace.inspect", tools.single().name) + assertEquals("beta-checklist.md", artifacts.single().name) + assertTrue(events.any { it is HermesStreamEvent.ToolUpdated }) + assertTrue(events.any { it is HermesStreamEvent.ArtifactCreated }) + } +}