feat: add transport domain contracts

This commit is contained in:
Hermes Agent
2026-07-24 10:23:21 +00:00
parent 464748c5bc
commit 983fb31801
12 changed files with 1391 additions and 42 deletions
+5
View File
@@ -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 <prompt>`, 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
+6 -6
View File
@@ -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:
@@ -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",
@@ -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<String>,
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<MessagePart>,
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<HermesMessage> = emptyList(),
val tools: Map<String, ToolResult> = emptyMap(),
val artifacts: Map<String, Artifact> = emptyMap(),
val error: String? = null
)
@@ -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<String, ToolResult> = emptyMap(),
artifacts: Map<String, Artifact> = 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<String, ToolResult>,
artifacts: Map<String, Artifact>
): 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"
}
@@ -0,0 +1,48 @@
package cloud.molberg.hermesmobile.domain
import kotlinx.coroutines.flow.Flow
interface ServerProfileRepository {
suspend fun listProfiles(): List<ServerProfile>
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<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)
}
interface HermesMessageRepository {
suspend fun listMessages(profileId: String, sessionId: String): List<HermesMessage>
suspend fun sendMessage(profileId: String, request: SendMessageRequest): SendMessageHandle
fun streamEvents(profileId: String, sessionId: String): Flow<HermesStreamEvent>
suspend fun cancel(profileId: String, sessionId: String)
}
interface ToolResultRepository {
suspend fun listToolResults(profileId: String, sessionId: String): List<ToolResult>
suspend fun getToolResult(profileId: String, resultId: String): ToolResult?
}
interface ArtifactRepository {
suspend fun listArtifacts(profileId: String, sessionId: String): List<Artifact>
suspend fun getArtifact(profileId: String, artifactId: String): Artifact?
}
interface HermesRepository :
ServerProfileRepository,
AuthRepository,
HermesSessionRepository,
HermesMessageRepository,
ToolResultRepository,
ArtifactRepository
@@ -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<HermesMessage>.upsert(message: HermesMessage): List<HermesMessage> =
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"
}
}
@@ -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<ServerProfile> = 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<HermesSession> =
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<HermesMessage> =
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<HermesStreamEvent> =
if (profiles.containsKey(profileId)) {
FakeHermesFixtures.stream(sessionId).map { it.toDomain() }.asFlow()
} else {
emptyList<HermesStreamEvent>().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<ToolResult> =
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<Artifact> =
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<StreamEventDto> =
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()) }
}
}
@@ -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<String> = 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<MessagePartDto>,
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
}
@@ -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)
}
}
@@ -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<HermesMessage> = emptyList()): HermesConversationState =
HermesConversationState(
session = HermesSession("s1", "Test", "Ready", SessionStatus.Idle, null, null, messages.size, null, null),
messages = messages
)
}
@@ -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 })
}
}