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 new file mode 100644 index 00000000..50ad8521 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronRunHistory.kt @@ -0,0 +1,37 @@ +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 4157489c..41057877 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,6 +21,7 @@ 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 @@ -339,6 +340,12 @@ 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 ddbc7235..b006d543 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,6 +8,7 @@ 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 @@ -18,6 +19,7 @@ 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 @@ -32,6 +34,7 @@ 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 @@ -49,6 +52,7 @@ 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 @@ -59,6 +63,7 @@ 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 @@ -75,6 +80,10 @@ 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( @@ -224,6 +233,15 @@ 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) }, ) { @@ -341,6 +359,14 @@ fun CronJobsScreen( ) } + if (state.runHistoryState.isOpen) { + CronRunHistoryDialog( + state = state.runHistoryState, + onRetry = viewModel::retryRunHistory, + onDismiss = viewModel::closeRunHistory, + ) + } + // ── Delete Confirm Dialog ── state.deleteTarget?.let { job -> AlertDialog( @@ -361,6 +387,139 @@ 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 27821eac..b7b8e9c9 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,6 +32,8 @@ 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(), @@ -41,6 +43,16 @@ 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( @@ -100,10 +112,13 @@ 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, @@ -111,6 +126,7 @@ class CronJobsViewModel : errorMessage = null, deleteTarget = null, editorState = CronJobEditorState(), + runHistoryState = CronRunHistoryState(), ) } } @@ -155,6 +171,100 @@ 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 374f6406..ed803776 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1148,6 +1148,7 @@ Pause Resume Run + Run history Edit Add cron job Edit Cron Job @@ -1188,6 +1189,17 @@ 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 a037dd9b..c8b16601 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,6 +99,108 @@ 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 97a5a730..829ddcbc 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,6 +125,20 @@ 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 fd1cc2c4..be16d085 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,6 +6,8 @@ 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 @@ -19,6 +21,7 @@ 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 @@ -125,6 +128,77 @@ 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()