feat(profiles): delete, rename, auto-describe and setup command (issue #781) (#818)

* feat(profiles): delete, rename, auto-describe and setup command (issue #781)

* feat(profiles): use shared ModelPickerDialog for set model (issue #781)

* fix(profiles): move overflow menu to card header so it can't be clipped (issue #781)

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-05 17:59:30 +03:00
1 parent 19c053b3fa
commit 80be168801
7 files changed
+868 -100

No files matched your search

@@ -57,6 +57,29 @@ data class UpdateProfileDescriptionRequest(
val description: String,
)
@Serializable
data class RenameProfileRequest(
val new_name: String,
)
@Serializable
data class ProfileDescribeAutoRequest(
val overwrite: Boolean = false,
)
@Serializable
data class ProfileDescribeAutoResponse(
val ok: Boolean,
val reason: String? = null,
val description: String? = null,
val description_auto: Boolean? = null,
)
@Serializable
data class ProfileSetupCommandResponse(
val command: String,
)
@Serializable
data class CreateProfileRequest(
val name: String,
@@ -69,11 +69,15 @@ import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.model.PluginProvidersPutRequest
import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.model.PortalResponse
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
import com.m57.hermescontrol.data.model.ProfileDescribeAutoResponse
import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse
import com.m57.hermescontrol.data.model.ProfileSoulResponse
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.PruneRequest
import com.m57.hermescontrol.data.model.RawConfigResponse
import com.m57.hermescontrol.data.model.RecentUnlock
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SaveSkillContentRequest
import com.m57.hermescontrol.data.model.ScanStatus
import com.m57.hermescontrol.data.model.SessionDetailResponse
@@ -358,6 +362,28 @@ interface HermesApiService {
@Body body: UpdateProfileDescriptionRequest,
): Response<Unit>
@PATCH("api/profiles/{name}")
suspend fun renameProfile(
@Path("name") name: String,
@Body body: RenameProfileRequest,
): Response<Unit>
@DELETE("api/profiles/{name}")
suspend fun deleteProfile(
@Path("name") name: String,
): Response<Unit>
@POST("api/profiles/{name}/describe-auto")
suspend fun describeProfileAuto(
@Path("name") name: String,
@Body body: ProfileDescribeAutoRequest,
): Response<ProfileDescribeAutoResponse>
@GET("api/profiles/{name}/setup-command")
suspend fun getProfileSetupCommand(
@Path("name") name: String,
): Response<ProfileSetupCommandResponse>
@GET("api/tools/toolsets")
suspend fun getToolsets(): Response<List<Toolset>>
@@ -28,6 +28,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -44,7 +45,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -57,6 +61,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
import com.m57.hermescontrol.ui.profiles.components.ProfileBuilderView
import com.m57.hermescontrol.ui.profiles.components.validateProfileName
@@ -71,8 +76,6 @@ fun ProfilesScreen(
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
var tempModelProvider by remember { mutableStateOf("") }
var tempModelName by remember { mutableStateOf("") }
var cloneProfileName by remember { mutableStateOf<String?>(null) }
var newCloneName by remember { mutableStateOf("") }
@@ -80,6 +83,16 @@ fun ProfilesScreen(
var descEditProfileName by remember { mutableStateOf<String?>(null) }
var tempDescription by remember { mutableStateOf("") }
var renameProfileName by remember { mutableStateOf<String?>(null) }
var newRenameName by remember { mutableStateOf("") }
var deleteProfileName by remember { mutableStateOf<String?>(null) }
var setupCmdProfileName by remember { mutableStateOf<String?>(null) }
var copiedSetupCmd by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
var isBuildingProfile by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -271,6 +284,107 @@ fun ProfilesScreen(
modifier = Modifier.padding(start = 8.dp),
)
}
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options",
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
if (profile.is_default != true) {
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_rename,
),
)
},
onClick = {
showMenu = false
renameProfileName = profile.name
newRenameName = profile.name
},
)
}
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_auto_describe,
),
)
},
enabled = !state.isAutoDescribing,
onClick = {
showMenu = false
viewModel.autoDescribeProfile(profile.name)
},
)
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_edit_description,
),
)
},
onClick = {
showMenu = false
descEditProfileName = profile.name
tempDescription = profile.description ?: ""
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.profiles_action_clone),
)
},
onClick = {
showMenu = false
cloneProfileName = profile.name
newCloneName = ""
},
)
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_setup_command,
),
)
},
onClick = {
showMenu = false
setupCmdProfileName = profile.name
},
)
if (profile.is_default != true) {
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error,
)
},
onClick = {
showMenu = false
deleteProfileName = profile.name
},
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
@@ -358,56 +472,12 @@ fun ProfilesScreen(
Button(
onClick = {
modelEditProfileName = profile.name
tempModelProvider = profile.provider ?: ""
tempModelName = profile.model ?: ""
viewModel.loadModelOptions()
},
modifier = Modifier.padding(end = 8.dp),
) {
Text(stringResource(R.string.profiles_action_set_model))
}
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options",
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = {
Text(
stringResource(
R.string.profiles_action_edit_description,
),
)
},
onClick = {
showMenu = false
descEditProfileName = profile.name
tempDescription = profile.description ?: ""
},
)
DropdownMenuItem(
text = {
Text(
stringResource(R.string.profiles_action_clone),
)
},
onClick = {
showMenu = false
cloneProfileName = profile.name
newCloneName = ""
},
)
}
}
}
}
}
@@ -486,57 +556,20 @@ fun ProfilesScreen(
}
if (modelEditProfileName != null) {
AlertDialog(
onDismissRequest = { modelEditProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_set_model,
modelEditProfileName.orEmpty(),
),
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = tempModelProvider,
onValueChange = { tempModelProvider = it },
label = { Text(stringResource(R.string.profiles_label_provider_input)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = tempModelName,
onValueChange = { tempModelName = it },
label = { Text(stringResource(R.string.profiles_label_model_input)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = {
val profileName = modelEditProfileName
if (profileName != null) {
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
}
modelEditProfileName = null
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(
onClick = { modelEditProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
ModelPickerDialog(
providers = state.modelProviders,
title = stringResource(R.string.profiles_title_set_model, modelEditProfileName.orEmpty()),
isLoading = state.isLoadingBuilderData && state.modelProviders.isEmpty(),
pinnedModels = state.modelPickerPinned,
onPinToggle = viewModel::togglePinModel,
onSelect = { provider, model ->
val profileName = modelEditProfileName
if (profileName != null) {
viewModel.updateModel(profileName, provider, model)
}
modelEditProfileName = null
},
onDismiss = { modelEditProfileName = null },
)
}
@@ -633,6 +666,205 @@ fun ProfilesScreen(
},
)
}
if (renameProfileName != null) {
AlertDialog(
onDismissRequest = { renameProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_rename,
renameProfileName.orEmpty(),
),
)
},
text = {
val errorMsg = if (newRenameName.isNotEmpty()) validateProfileName(newRenameName) else null
OutlinedTextField(
value = newRenameName,
onValueChange = { newRenameName = it },
label = { Text(stringResource(R.string.profiles_label_new_name_input)) },
isError = errorMsg != null,
supportingText = errorMsg?.let { { Text(it) } },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = {
val oldName = renameProfileName
if (oldName != null) {
viewModel.renameProfile(oldName, newRenameName.trim())
}
renameProfileName = null
},
enabled =
newRenameName.isNotBlank() &&
newRenameName != renameProfileName &&
validateProfileName(newRenameName) == null,
) {
Text(stringResource(R.string.profiles_action_rename))
}
},
dismissButton = {
TextButton(
onClick = { renameProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
if (deleteProfileName != null) {
val profileToDelete = state.profiles.firstOrNull { it.name == deleteProfileName }
AlertDialog(
onDismissRequest = { deleteProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_delete,
deleteProfileName.orEmpty(),
),
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text =
stringResource(
R.string.profiles_delete_warning,
deleteProfileName.orEmpty(),
),
)
profileToDelete?.path?.let {
Text(
text = stringResource(R.string.profiles_delete_path, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (profileToDelete?.gateway_running == true) {
Text(
text = stringResource(R.string.profiles_delete_gateway_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
val name = deleteProfileName
if (name != null) {
viewModel.deleteProfile(name)
}
deleteProfileName = null
},
) {
Text(
text = stringResource(R.string.action_delete),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(
onClick = { deleteProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
if (setupCmdProfileName != null) {
LaunchedEffect(setupCmdProfileName) {
copiedSetupCmd = false
setupCmdProfileName?.let { viewModel.fetchSetupCommand(it) }
}
AlertDialog(
onDismissRequest = { setupCmdProfileName = null },
title = {
Text(
text =
stringResource(
R.string.profiles_title_setup_command,
setupCmdProfileName.orEmpty(),
),
)
},
text = {
if (state.isLoadingSetupCommand) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(80.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.profiles_setup_command_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(12.dp),
) {
Text(
text = state.setupCommand.orEmpty(),
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace,
),
)
}
}
}
},
confirmButton = {
if (!state.isLoadingSetupCommand && state.setupCommand != null) {
Button(
onClick = {
clipboardManager.setText(AnnotatedString(state.setupCommand.orEmpty()))
copiedSetupCmd = true
},
) {
Text(
text =
stringResource(
if (copiedSetupCmd) {
R.string.profiles_action_copied
} else {
R.string.profiles_action_copy
},
),
)
}
}
},
dismissButton = {
TextButton(
onClick = { setupCmdProfileName = null },
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
// ---------------------------------------------------------------------
@@ -2,11 +2,15 @@ package com.m57.hermescontrol.ui.profiles
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.CloneProfileRequest
import com.m57.hermescontrol.data.model.CreateProfileRequest
import com.m57.hermescontrol.data.model.HubSkill
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
@@ -34,6 +38,12 @@ data class ProfilesUiState(
val isLoadingSoul: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
// Rename / delete / auto-describe / setup-command states
val isAutoDescribing: Boolean = false,
val setupCommand: String? = null,
val isLoadingSetupCommand: Boolean = false,
// Model picker (issue #781 — Set Model uses the shared ModelPickerDialog)
val modelPickerPinned: List<PinnedModel> = emptyList(),
// Profile Builder states
val modelProviders: List<ModelProvider> = emptyList(),
val isLoadingBuilderData: Boolean = false,
@@ -296,6 +306,198 @@ class ProfilesViewModel :
}
}
fun renameProfile(
oldName: String,
newName: String,
) {
if (oldName == newName) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.renameProfile(
oldName,
RenameProfileRequest(newName),
)
}
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Profile '$oldName' renamed to '$newName'",
)
}
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to rename profile '$oldName': ${result.error.message}",
)
}
}
}
}
}
fun deleteProfile(name: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.deleteProfile(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
toastMessage = "Profile '$name' deleted",
)
}
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete profile '$name': ${result.error.message}",
)
}
}
}
}
}
fun autoDescribeProfile(name: String) {
_uiState.update { it.copy(isAutoDescribing = true) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.describeProfileAuto(
name,
ProfileDescribeAutoRequest(overwrite = true),
)
}
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
if (body.ok) {
_uiState.update {
it.copy(
isAutoDescribing = false,
toastMessage = "Auto-described profile '$name'",
)
}
loadProfiles()
} else {
// Backend returns generation failures as ok:false + reason,
// NOT an HTTP error — surface the reason to the user.
_uiState.update {
it.copy(
isAutoDescribing = false,
toastMessage =
"Auto-describe failed for '$name': " +
(body.reason ?: "unknown reason"),
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isAutoDescribing = false,
toastMessage = "Failed to auto-describe profile '$name': ${result.error.message}",
)
}
}
}
}
}
fun fetchSetupCommand(name: String) {
_uiState.update { it.copy(isLoadingSetupCommand = true, setupCommand = null) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getProfileSetupCommand(name) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingSetupCommand = false,
setupCommand = result.data.command,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoadingSetupCommand = false,
toastMessage = "Failed to fetch setup command: ${result.error.message}",
)
}
}
}
}
}
fun loadModelOptions() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getModelOptions() }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoadingBuilderData = false,
modelProviders = result.data.providers,
modelPickerPinned = AuthManager.getPinnedModels(),
)
}
}
is NetworkResult.Failure -> {
// Toast, not errorMessage — a picker-load failure must NOT
// blank the profiles list with the ErrorState branch.
_uiState.update {
it.copy(
isLoadingBuilderData = false,
toastMessage = "Failed to load models: ${result.error.message}",
)
}
}
}
}
}
fun togglePinModel(
providerSlug: String,
modelName: String,
) {
val target = PinnedModel(providerSlug, modelName)
val current = AuthManager.getPinnedModels().toMutableList()
if (target in current) {
current.remove(target)
} else {
current.add(target)
}
AuthManager.savePinnedModels(current)
_uiState.update { it.copy(modelPickerPinned = current) }
}
fun loadBuilderData() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch {
+12 -2
View File
@@ -397,11 +397,21 @@
<string name="profiles_title_set_model">모델 설정: %1$s</string>
<string name="profiles_title_clone">프로필 복제: %1$s</string>
<string name="profiles_title_edit_description">설명 편집: %1$s</string>
<string name="profiles_label_provider_input">제공자</string>
<string name="profiles_label_model_input">모델명</string>
<string name="profiles_label_new_name_input">새 프로필명</string>
<string name="profiles_label_description_input">설명</string>
<string name="profiles_description_placeholder">설명이 없습니다. 이 프로필의 역할을 설명해주세요.</string>
<string name="profiles_action_rename">이름 변경</string>
<string name="profiles_action_auto_describe">자동 설명</string>
<string name="profiles_action_setup_command">설정 명령</string>
<string name="profiles_action_copy">복사</string>
<string name="profiles_action_copied">복사됨 ✓</string>
<string name="profiles_title_rename">프로필 이름 변경: %1$s</string>
<string name="profiles_title_delete">프로필 삭제: %1$s</string>
<string name="profiles_title_setup_command">설정 명령: %1$s</string>
<string name="profiles_delete_warning">프로필 \'%1$s\'과(와) 그 데이터를 영구 삭제합니다.</string>
<string name="profiles_delete_path">경로: %1$s</string>
<string name="profiles_delete_gateway_warning">이 프로필은 실행 중인 게이트웨이가 있습니다 — 삭제하면 중지됩니다.</string>
<string name="profiles_setup_command_hint">Hermes CLI에서 이 프로필을 설정하려면 이 명령을 실행하세요.</string>
<string name="profiles_builder_step_indicator">%1$d / 5 단계</string>
<string name="profiles_builder_step_identity">아이덴티티</string>
<string name="profiles_builder_step_model">모델 설정</string>
+12 -2
View File
@@ -572,11 +572,21 @@
<string name="profiles_title_set_model">Set Model: %1$s</string>
<string name="profiles_title_clone">Clone Profile: %1$s</string>
<string name="profiles_title_edit_description">Edit Description: %1$s</string>
<string name="profiles_label_provider_input">Provider</string>
<string name="profiles_label_model_input">Model Name</string>
<string name="profiles_label_new_name_input">New Profile Name</string>
<string name="profiles_label_description_input">Description</string>
<string name="profiles_description_placeholder">No description provided. Describe this profile\'s role.</string>
<string name="profiles_action_rename">Rename</string>
<string name="profiles_action_auto_describe">Auto-Describe</string>
<string name="profiles_action_setup_command">Setup Command</string>
<string name="profiles_action_copy">Copy</string>
<string name="profiles_action_copied">Copied ✓</string>
<string name="profiles_title_rename">Rename Profile: %1$s</string>
<string name="profiles_title_delete">Delete Profile: %1$s</string>
<string name="profiles_title_setup_command">Setup Command: %1$s</string>
<string name="profiles_delete_warning">This permanently deletes the profile \'%1$s\' and its data.</string>
<string name="profiles_delete_path">Path: %1$s</string>
<string name="profiles_delete_gateway_warning">This profile has a running gateway — deleting it will stop it.</string>
<string name="profiles_setup_command_hint">Run this command in the Hermes CLI to configure this profile.</string>
<string name="profiles_builder_step_indicator">Step %1$d of 5</string>
<string name="profiles_builder_step_identity">Identity</string>
<string name="profiles_builder_step_model">Model Configuration</string>
@@ -0,0 +1,265 @@
package com.m57.hermescontrol.ui.profiles
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.ActiveProfileResponse
import com.m57.hermescontrol.data.model.ModelOptionsResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.ProfileDescribeAutoRequest
import com.m57.hermescontrol.data.model.ProfileDescribeAutoResponse
import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse
import com.m57.hermescontrol.data.model.ProfilesResponse
import com.m57.hermescontrol.data.model.RenameProfileRequest
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
/**
* Issue #781 — Profiles screen delete/rename/auto-describe/setup-command.
* Contracts verified against hermes-agent web_routers/profiles.py:
* - PATCH /api/profiles/{name} {new_name} -> {ok, name, path}
* - DELETE /api/profiles/{name} -> {ok, path}
* - POST /api/profiles/{name}/describe-auto {overwrite} -> {ok, reason, description, description_auto}
* (generation failures come back as ok:false + reason, NOT an HTTP error)
* - GET /api/profiles/{name}/setup-command -> {command}
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ProfilesViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private var storedPinnedModels: MutableList<PinnedModel> = mutableListOf()
private fun stubProfilesLoad() {
coEvery { mockApi.getProfiles() } returns
Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true))))
coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default"))
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
val testMainDispatcher = Dispatchers.Main
mockkStatic(Dispatchers::class)
every { Dispatchers.IO } returns testDispatcher
every { Dispatchers.Main } returns testMainDispatcher
mockkObject(AuthManager)
storedPinnedModels = mutableListOf()
every { AuthManager.getPinnedModels() } answers { storedPinnedModels.toList() }
every { AuthManager.savePinnedModels(any()) } answers {
storedPinnedModels = firstArg<List<PinnedModel>>().toMutableList()
}
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
stubProfilesLoad()
}
private fun <T> errorResponse(code: Int): Response<T> = Response.error(code, "{}".toResponseBody(null))
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
private fun createViewModel(): ProfilesViewModel {
val vm = ProfilesViewModel()
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@Test
fun `renameProfile success calls backend and reloads`() {
coEvery { mockApi.renameProfile(any(), any()) } returns Response.success(Unit)
val vm = createViewModel()
vm.renameProfile("work", "play")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.renameProfile("work", RenameProfileRequest("play")) }
assertTrue(vm.uiState.value.toastMessage!!.contains("renamed"))
// reload happened
coVerify { mockApi.getProfiles() }
}
@Test
fun `renameProfile to same name is a no-op`() {
// If the VM ever fired the API with an unchanged name, this stub throws
// and the test fails — no fragile exactly=0 verification needed.
coEvery { mockApi.renameProfile(any(), any()) } throws IllegalStateException("should not be called")
val vm = createViewModel()
vm.renameProfile("work", "work")
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.toastMessage)
}
@Test
fun `renameProfile failure surfaces toast and stops loading`() {
coEvery { mockApi.renameProfile(any(), any()) } returns errorResponse(404)
val vm = createViewModel()
vm.renameProfile("work", "play")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to rename"))
assertFalse(vm.uiState.value.isLoading)
}
@Test
fun `deleteProfile success calls backend and reloads`() {
coEvery { mockApi.deleteProfile(any()) } returns Response.success(Unit)
val vm = createViewModel()
vm.deleteProfile("work")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.deleteProfile("work") }
assertTrue(vm.uiState.value.toastMessage!!.contains("deleted"))
coVerify { mockApi.getProfiles() }
}
@Test
fun `deleteProfile failure surfaces toast`() {
coEvery { mockApi.deleteProfile(any()) } returns errorResponse(404)
val vm = createViewModel()
vm.deleteProfile("work")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to delete"))
}
@Test
fun `autoDescribeProfile success with ok true reloads`() {
coEvery { mockApi.describeProfileAuto(any(), any()) } returns
Response.success(ProfileDescribeAutoResponse(ok = true, description = "desc", description_auto = true))
val vm = createViewModel()
vm.autoDescribeProfile("work")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.describeProfileAuto("work", ProfileDescribeAutoRequest(overwrite = true)) }
assertTrue(vm.uiState.value.toastMessage!!.contains("Auto-described"))
assertFalse(vm.uiState.value.isAutoDescribing)
coVerify { mockApi.getProfiles() }
}
@Test
fun `autoDescribeProfile ok false surfaces backend reason`() {
coEvery { mockApi.describeProfileAuto(any(), any()) } returns
Response.success(ProfileDescribeAutoResponse(ok = false, reason = "no aux client configured"))
val vm = createViewModel()
vm.autoDescribeProfile("work")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("no aux client configured"))
assertFalse(vm.uiState.value.isAutoDescribing)
}
@Test
fun `fetchSetupCommand success stores command`() {
coEvery { mockApi.getProfileSetupCommand(any()) } returns
Response.success(ProfileSetupCommandResponse(command = "work setup"))
val vm = createViewModel()
vm.fetchSetupCommand("work")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.getProfileSetupCommand("work") }
assertEquals("work setup", vm.uiState.value.setupCommand)
assertFalse(vm.uiState.value.isLoadingSetupCommand)
}
@Test
fun `fetchSetupCommand failure leaves command null and toasts`() {
coEvery { mockApi.getProfileSetupCommand(any()) } returns errorResponse(404)
val vm = createViewModel()
vm.fetchSetupCommand("work")
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.setupCommand)
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to fetch setup command"))
}
@Test
fun `loadModelOptions success populates providers and pins`() {
storedPinnedModels.add(PinnedModel("openai", "gpt-4"))
coEvery { mockApi.getModelOptions() } returns
Response.success(
ModelOptionsResponse(
listOf(
ModelProvider(
slug = "openai",
name = "OpenAI",
models = listOf("gpt-4o", "gpt-4o-mini"),
),
),
),
)
val vm = createViewModel()
vm.loadModelOptions()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(1, vm.uiState.value.modelProviders.size)
assertEquals("gpt-4o", vm.uiState.value.modelProviders[0].models!![0])
assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned)
assertFalse(vm.uiState.value.isLoadingBuilderData)
}
@Test
fun `loadModelOptions failure toasts without blanking errorMessage`() {
coEvery { mockApi.getModelOptions() } returns errorResponse(500)
val vm = createViewModel()
vm.loadModelOptions()
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage!!.contains("Failed to load models"))
assertNull(vm.uiState.value.errorMessage)
assertFalse(vm.uiState.value.isLoadingBuilderData)
}
@Test
fun `togglePinModel adds then removes pinned model`() {
val vm = createViewModel()
vm.togglePinModel("openai", "gpt-4")
assertEquals(listOf(PinnedModel("openai", "gpt-4")), vm.uiState.value.modelPickerPinned)
assertEquals(listOf(PinnedModel("openai", "gpt-4")), storedPinnedModels)
vm.togglePinModel("openai", "gpt-4")
assertTrue(vm.uiState.value.modelPickerPinned.isEmpty())
assertTrue(storedPinnedModels.isEmpty())
}
}