feat: add transport domain contracts
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
+3
-36
@@ -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",
|
||||
|
||||
+187
@@ -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
|
||||
)
|
||||
+279
@@ -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"
|
||||
}
|
||||
+48
@@ -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
|
||||
+72
@@ -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"
|
||||
}
|
||||
}
|
||||
+292
@@ -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()) }
|
||||
}
|
||||
}
|
||||
+188
@@ -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
|
||||
}
|
||||
+169
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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
|
||||
)
|
||||
}
|
||||
+65
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user