diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt deleted file mode 100644 index 50ad8521..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.m57.hermescontrol.data.model - -import kotlinx.serialization.Serializable - -@Serializable -data class CronRunHistoryResponse( - val runs: List = emptyList(), - val limit: Int = 20, -) - -/** - * One Hermes Agent cron execution. - * - * The backend exposes cron runs using the same enriched row shape as - * `/api/sessions`, then annotates each row with its owning profile and - * active/archive state. Only [id] is required; older backend rows can omit any - * of the enrichment fields without making the whole history unusable. - */ -@Serializable -data class CronRun( - val id: String, - val source: String? = null, - val model: String? = null, - val title: String? = null, - val started_at: Double? = null, - val ended_at: Double? = null, - val last_active: Double? = null, - val is_active: Boolean = false, - val archived: Boolean = false, - val message_count: Int? = null, - val tool_call_count: Int? = null, - val input_tokens: Int? = null, - val output_tokens: Int? = null, - val preview: String? = null, - val parent_session_id: String? = null, - val profile: String? = null, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 38d95160..7b0a393b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -21,7 +21,6 @@ import com.m57.hermescontrol.data.model.CreateWebhookRequest import com.m57.hermescontrol.data.model.CredentialPoolResponse import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.CronRunHistoryResponse import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse @@ -360,12 +359,6 @@ interface HermesApiService : KanbanApiService { @GET("api/cron/jobs") suspend fun getCronJobs(): Response> - @GET("api/cron/jobs/{id}/runs") - suspend fun getCronJobRuns( - @Path("id") id: String, - @Query("limit") limit: Int = 20, - ): Response - @POST("api/cron/jobs/{id}/pause") suspend fun pauseCronJob( @Path("id") id: String, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index b006d543..ddbc7235 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -19,7 +18,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,7 +32,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -52,7 +49,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -63,7 +59,6 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.CronRun import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState @@ -80,10 +75,6 @@ import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.util.CronExpressionFormatter -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.FormatStyle @Composable fun CronJobsScreen( @@ -233,15 +224,6 @@ fun CronJobsScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - IconButton( - onClick = { viewModel.openRunHistory(job) }, - ) { - Icon( - imageVector = Icons.Filled.History, - contentDescription = stringResource(R.string.cron_action_history), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } IconButton( onClick = { viewModel.openEditJobDialog(job.id) }, ) { @@ -359,14 +341,6 @@ fun CronJobsScreen( ) } - if (state.runHistoryState.isOpen) { - CronRunHistoryDialog( - state = state.runHistoryState, - onRetry = viewModel::retryRunHistory, - onDismiss = viewModel::closeRunHistory, - ) - } - // ── Delete Confirm Dialog ── state.deleteTarget?.let { job -> AlertDialog( @@ -387,139 +361,6 @@ fun CronJobsScreen( } } -@Composable -private fun CronRunHistoryDialog( - state: CronRunHistoryState, - onRetry: () -> Unit, - onDismiss: () -> Unit, -) { - val spacing = LocalSpacing.current - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text( - text = stringResource(R.string.cron_history_title, state.jobName), - style = MaterialTheme.typography.titleLarge, - ) - }, - text = { - when { - state.isLoading -> { - LoadingState( - modifier = Modifier.height(240.dp), - subtitle = stringResource(R.string.cron_history_loading), - ) - } - - state.errorMessage != null -> { - ErrorState( - message = state.errorMessage, - onRetry = onRetry, - modifier = Modifier.height(240.dp), - ) - } - - state.runs.isEmpty() -> { - EmptyState( - icon = Icons.Filled.History, - title = stringResource(R.string.cron_history_empty_title), - subtitle = stringResource(R.string.cron_history_empty_desc), - modifier = Modifier.height(240.dp), - ) - } - - else -> { - LazyColumn( - modifier = - Modifier - .fillMaxWidth() - .heightIn(max = 420.dp), - verticalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - items(state.runs, key = { it.id }) { run -> - CronRunRow(run) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - } - } - } - } - }, - confirmButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.cron_history_close)) - } - }, - ) -} - -@Composable -private fun CronRunRow(run: CronRun) { - val spacing = LocalSpacing.current - val (statusText, statusType) = - when { - run.is_active -> stringResource(R.string.cron_history_status_active) to StatusBadgeType.SUCCESS - run.archived -> stringResource(R.string.cron_history_status_archived) to StatusBadgeType.NEUTRAL - run.ended_at != null -> stringResource(R.string.cron_history_status_completed) to StatusBadgeType.INFO - else -> stringResource(R.string.cron_history_status_inactive) to StatusBadgeType.NEUTRAL - } - val startedAt = formatCronRunTime(run.started_at) - - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = spacing.xs), - verticalArrangement = Arrangement.spacedBy(spacing.xs), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = startedAt ?: run.id, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - StatusBadge(text = statusText, status = statusType) - } - if (!run.profile.isNullOrBlank()) { - Text( - text = stringResource(R.string.cron_history_profile, run.profile), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (run.message_count != null) { - Text( - text = stringResource(R.string.cron_history_messages, run.message_count), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - if (!run.preview.isNullOrBlank()) { - Text( - text = run.preview, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -private fun formatCronRunTime(epochSeconds: Double?): String? { - if (epochSeconds == null) return null - return runCatching { - Instant - .ofEpochMilli((epochSeconds * 1000).toLong()) - .atZone(ZoneId.systemDefault()) - .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)) - }.getOrNull() -} - @Composable fun CronJobEditorDialog( state: CronJobEditorState, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt index b7b8e9c9..27821eac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsViewModel.kt @@ -3,22 +3,22 @@ package com.m57.hermescontrol.ui.cron import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.local.DataScope import com.m57.hermescontrol.data.local.SwrCache import com.m57.hermescontrol.data.model.CreateCronJobRequest import com.m57.hermescontrol.data.model.CronBlueprint import com.m57.hermescontrol.data.model.CronJob -import com.m57.hermescontrol.data.model.CronRun import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.UpdateCronJobRequest import com.m57.hermescontrol.data.remote.ApiClient -import com.m57.hermescontrol.data.remote.NetworkError import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.ws.ChangeEvents import com.m57.hermescontrol.data.ws.toJsonElement import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.refreshOnChange +import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -32,8 +32,6 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -private const val CRON_RUN_HISTORY_LIMIT = 20 - data class CronJobsUiState( val isLoading: Boolean = false, val jobs: List = emptyList(), @@ -43,16 +41,6 @@ data class CronJobsUiState( val deleteTarget: CronJob? = null, // Editor state val editorState: CronJobEditorState = CronJobEditorState(), - val runHistoryState: CronRunHistoryState = CronRunHistoryState(), -) - -data class CronRunHistoryState( - val isOpen: Boolean = false, - val jobId: String? = null, - val jobName: String = "", - val isLoading: Boolean = false, - val runs: List = emptyList(), - val errorMessage: String? = null, ) data class CronJobEditorState( @@ -112,13 +100,10 @@ class CronJobsViewModel : val uiState: StateFlow = _uiState.asStateFlow() private var loadJob: Job? = null - private var runHistoryJob: Job? = null internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO private val jobsCache = SwrCache>() fun clearScopeOwnedState() { - runHistoryJob?.cancel() - runHistoryJob = null _uiState.update { it.copy( isLoading = false, @@ -126,7 +111,6 @@ class CronJobsViewModel : errorMessage = null, deleteTarget = null, editorState = CronJobEditorState(), - runHistoryState = CronRunHistoryState(), ) } } @@ -171,100 +155,6 @@ class CronJobsViewModel : ) } - fun openRunHistory(job: CronJob) { - runHistoryJob?.cancel() - _uiState.update { - it.copy( - runHistoryState = - CronRunHistoryState( - isOpen = true, - jobId = job.id, - jobName = job.name, - isLoading = true, - ), - ) - } - loadRunHistory(job.id) - } - - fun retryRunHistory() { - val jobId = _uiState.value.runHistoryState.jobId ?: return - _uiState.update { - it.copy( - runHistoryState = - it.runHistoryState.copy( - isLoading = true, - errorMessage = null, - ), - ) - } - loadRunHistory(jobId) - } - - fun closeRunHistory() { - runHistoryJob?.cancel() - runHistoryJob = null - _uiState.update { it.copy(runHistoryState = CronRunHistoryState()) } - } - - private fun loadRunHistory(jobId: String) { - val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() - runHistoryJob = - viewModelScope.launch { - val result = - withContext(ioDispatcher) { - safeApiCall { - ApiClient.hermesApi.getCronJobRuns( - id = jobId, - limit = CRON_RUN_HISTORY_LIMIT, - ) - } - } - val currentScope = runCatching { AuthManager.currentDataScope() }.getOrNull() - val history = _uiState.value.runHistoryState - if (!history.isOpen || - history.jobId != jobId || - (requestScope != null && currentScope != requestScope) - ) { - return@launch - } - - when (result) { - is NetworkResult.Success -> { - _uiState.update { - it.copy( - runHistoryState = - it.runHistoryState.copy( - isLoading = false, - runs = result.data.runs, - errorMessage = null, - ), - ) - } - } - - is NetworkResult.Failure -> { - val message = - if (result.error is NetworkError.Http && result.error.code == 404) { - "Run history is unavailable on this Hermes Agent version." - } else { - "Failed to load run history: ${result.error.message}" - } - _uiState.update { - it.copy( - runHistoryState = - it.runHistoryState.copy( - isLoading = false, - runs = emptyList(), - errorMessage = message, - ), - ) - } - } - } - } - } - fun pauseCronJob(id: String) { val originalJobs = _uiState.value.jobs val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f8e0e58..206f0415 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1161,7 +1161,6 @@ Pause Resume Run - Run history Edit Add cron job Edit Cron Job @@ -1202,17 +1201,6 @@ You have unsaved changes. Discard them? Delete job? Delete \"%1$s\"? This can\'t be undone. - %1$s runs - Loading run history - No runs yet - Recent executions for this job will appear here. - Active - Archived - Completed - Inactive - Profile: %1$s - Messages: %1$d - Close No achievements yet diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt index c8b16601..a037dd9b 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/HermesApiServiceTest.kt @@ -99,108 +99,6 @@ class HermesApiServiceTest { assertEquals("GET", recordedRequest.method) } - @Test - fun testGetCronJobRuns_requestsBoundedHistoryAndParsesResponse() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody( - """ - { - "runs": [ - { - "id": "cron_job_1_1700000000", - "source": "cron", - "model": "gpt", - "title": "Backup", - "started_at": 1700000000.5, - "ended_at": 1700000020.0, - "last_active": 1700000020.0, - "is_active": false, - "archived": true, - "message_count": 2, - "tool_call_count": 1, - "input_tokens": 123, - "output_tokens": 45, - "preview": "done", - "profile": "worker" - } - ], - "limit": 20 - } - """.trimIndent(), - ), - ) - - val response = apiService.getCronJobRuns("job_1", limit = 20) - - assertTrue(response.isSuccessful) - val run = response.body()?.runs?.single() - assertNotNull(run) - assertEquals("cron_job_1_1700000000", run?.id) - assertEquals("worker", run?.profile) - assertEquals(true, run?.archived) - assertEquals(2, run?.message_count) - assertEquals("done", run?.preview) - - val recordedRequest = mockWebServer.takeRequest() - assertEquals("/api/cron/jobs/job_1/runs?limit=20", recordedRequest.path) - assertEquals("GET", recordedRequest.method) - } - - @Test - fun testGetCronJobRuns_emptyResponse() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"runs":[],"limit":20}"""), - ) - - val response = apiService.getCronJobRuns("job_1") - - assertTrue(response.isSuccessful) - assertTrue(response.body()?.runs?.isEmpty() == true) - } - - @Test - fun testGetCronJobRuns_missingOptionalFieldsUsesDefaults() = - runTest { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"runs":[{"id":"cron_job_1_1"}],"limit":20}"""), - ) - - val run = - apiService - .getCronJobRuns("job_1") - .body() - ?.runs - ?.single() - - assertNotNull(run) - assertNull(run?.started_at) - assertEquals(false, run?.is_active) - assertEquals(false, run?.archived) - } - - @Test - fun testGetCronJobRuns_missingRequiredIdThrows() { - mockWebServer.enqueue( - MockResponse() - .setResponseCode(200) - .setBody("""{"runs":[{"started_at":1.0}],"limit":20}"""), - ) - - assertThrows(kotlinx.serialization.SerializationException::class.java) { - runTest { - apiService.getCronJobRuns("job_1") - } - } - } - @Test fun testPauseCronJob_sendsPostRequest() = runTest { diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt index 829ddcbc..97a5a730 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/ProfileScopeInterceptorTest.kt @@ -125,20 +125,6 @@ class ProfileScopeInterceptorTest { } } - @Test - fun cronRunHistory_preservesLimitAndFollowsActiveProfileAcrossSwitches() { - val client = clientFor("alpha") - - val alphaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20") - assertEquals("alpha", alphaUrl.queryParameter("profile")) - assertEquals("20", alphaUrl.queryParameter("limit")) - - AuthManager.setActiveProfileId("beta") - val betaUrl = requestedUrl(client, "api/cron/jobs/job-1/runs?limit=20") - assertEquals("beta", betaUrl.queryParameter("profile")) - assertEquals("20", betaUrl.queryParameter("limit")) - } - @Test fun skillsEndpoint_isScoped() { val client = clientFor("alpha") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt index be16d085..fd1cc2c4 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/cron/CronJobsViewModelTest.kt @@ -6,8 +6,6 @@ import com.m57.hermescontrol.data.model.CronBlueprintField import com.m57.hermescontrol.data.model.CronBlueprintListResponse import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJobFireError -import com.m57.hermescontrol.data.model.CronRun -import com.m57.hermescontrol.data.model.CronRunHistoryResponse import com.m57.hermescontrol.data.model.DeliveryTarget import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -21,7 +19,6 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.slot import io.mockk.unmockkAll -import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -128,77 +125,6 @@ class CronJobsViewModelTest { assertEquals(listOf("origin", "local", "telegram"), editor.deliveryOptions) } - @Test - fun `openRunHistory loads bounded recent runs`() { - coEvery { mockApi.getCronJobRuns("j1", 20) } returns - Response.success( - CronRunHistoryResponse( - runs = - listOf( - CronRun( - id = "cron_j1_1", - started_at = 1_700_000_000.0, - is_active = true, - profile = "default", - ), - ), - limit = 20, - ), - ) - val vm = createViewModel() - - vm.openRunHistory(CronJob(id = "j1", name = "Morning")) - settle() - - val history = vm.uiState.value.runHistoryState - assertTrue(history.isOpen) - assertFalse(history.isLoading) - assertEquals("j1", history.jobId) - assertEquals("Morning", history.jobName) - assertEquals(listOf("cron_j1_1"), history.runs.map { it.id }) - coVerify(exactly = 1) { mockApi.getCronJobRuns("j1", 20) } - } - - @Test - fun `openRunHistory explains unsupported backend`() { - coEvery { mockApi.getCronJobRuns("j1", 20) } returns - Response.error(404, "{}".toResponseBody()) - val vm = createViewModel() - - vm.openRunHistory(CronJob(id = "j1", name = "Morning")) - settle() - - val history = vm.uiState.value.runHistoryState - assertFalse(history.isLoading) - assertTrue(history.runs.isEmpty()) - assertTrue(history.errorMessage.orEmpty().contains("unavailable")) - } - - @Test - fun `scope reset cancels in flight run history and clears rows`() { - val response = CompletableDeferred>() - coEvery { mockApi.getCronJobRuns("j1", 20) } coAnswers { response.await() } - val vm = createViewModel() - - vm.openRunHistory(CronJob(id = "j1", name = "Morning")) - testDispatcher.scheduler.runCurrent() - vm.clearScopeOwnedState() - response.complete( - Response.success( - CronRunHistoryResponse( - runs = listOf(CronRun(id = "cron_j1_stale", profile = "old-profile")), - ), - ), - ) - settle() - - val history = vm.uiState.value.runHistoryState - assertFalse(history.isOpen) - assertFalse(history.isLoading) - assertTrue(history.runs.isEmpty()) - assertNull(history.jobId) - } - @Test fun `selectBlueprint seeds slot defaults`() { val vm = createViewModel()