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
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### 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 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 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.
|
- 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.
|
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
||||||
|
|
||||||
### Changed
|
### 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`.
|
- 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 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.
|
- 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.
|
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
|
||||||
|
|
||||||
### Verification
|
### 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
|
- `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:testDebugUnitTest :app:assembleDebug` from `apps/mobile/android` — passed
|
||||||
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :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
|
## 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**
|
- [ ] **B2 — Connection and authentication UX**
|
||||||
- Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states.
|
- Server connection screen with validation, health, auth/session configuration, offline/unauthorized/retry states.
|
||||||
- Store sensitive values using Android-appropriate secure storage; never log secrets.
|
- 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.
|
- 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.
|
- 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
|
## Work-loop checklist
|
||||||
|
|
||||||
Before coding:
|
Before coding:
|
||||||
|
|||||||
+3
-36
@@ -1,43 +1,10 @@
|
|||||||
package cloud.molberg.hermesmobile.chat
|
package cloud.molberg.hermesmobile.chat
|
||||||
|
|
||||||
|
import cloud.molberg.hermesmobile.fake.FakeHermesRepository
|
||||||
|
|
||||||
object FakeChatReducer {
|
object FakeChatReducer {
|
||||||
fun seed(): FakeChatUiState {
|
fun seed(): FakeChatUiState {
|
||||||
val release = ChatSessionUi(
|
val release = FakeHermesRepository.previewChatState().selectedSession ?: return empty()
|
||||||
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 reconnecting = ChatSessionUi(
|
val reconnecting = ChatSessionUi(
|
||||||
id = "session-reconnect",
|
id = "session-reconnect",
|
||||||
title = "Reconnecting stream",
|
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