diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionOpsResponses.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionOpsResponses.kt new file mode 100644 index 00000000..904d20f7 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionOpsResponses.kt @@ -0,0 +1,28 @@ +package com.m57.hermescontrol.data.model +import kotlinx.serialization.Serializable + +/** GET /api/sessions/empty/count — count of empty, ended, non-archived sessions. */ +@Serializable +data class EmptySessionsCountResponse( + val count: Int = 0, +) + +/** DELETE /api/sessions/empty — bulk cleanup result. */ +@Serializable +data class EmptySessionsDeleteResponse( + val ok: Boolean = false, + val deleted: Int = 0, +) + +/** + * GET /api/sessions/{id}/latest-descendant — a branched session's "current" + * row is its newest child leaf; resume THAT instead of the stale parent + * (desktop parity, issue #787). + */ +@Serializable +data class LatestDescendantResponse( + val requested_session_id: String? = null, + val session_id: String? = null, + val path: List = emptyList(), + val changed: Boolean = false, +) 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 7ebce695..6d5c002c 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 @@ -27,6 +27,8 @@ import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DeleteWebhookResponse import com.m57.hermescontrol.data.model.DeliveryTargetsResponse import com.m57.hermescontrol.data.model.DoctorResponse +import com.m57.hermescontrol.data.model.EmptySessionsCountResponse +import com.m57.hermescontrol.data.model.EmptySessionsDeleteResponse import com.m57.hermescontrol.data.model.EnvVarConfig import com.m57.hermescontrol.data.model.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest @@ -37,6 +39,7 @@ import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse +import com.m57.hermescontrol.data.model.LatestDescendantResponse import com.m57.hermescontrol.data.model.LearningGraphResponse import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.ManagedDirectoryCreate @@ -217,6 +220,18 @@ interface HermesApiService { @Body body: PruneRequest, ): Response + // ── Admin: Empty-session cleanup + branch resolution (issue #787) ── + @GET("api/sessions/empty/count") + suspend fun getEmptySessionCount(): Response + + @DELETE("api/sessions/empty") + suspend fun deleteEmptySessions(): Response + + @GET("api/sessions/{id}/latest-descendant") + suspend fun getLatestDescendant( + @Path("id", encoded = true) sessionId: String, + ): Response + @GET("api/sessions/{id}/prompt") suspend fun getSessionPrompt( // Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468). diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 9a98e9f4..56d24439 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -1761,17 +1761,46 @@ class ChatViewModel( val title = _uiState.value.sessions.find { it.id == sessionId }?.title ?: "Hermes" val generation = resetSessionState(sessionId, title, isLoading = true) viewModelScope.launch { + // Desktop parity (issue #787): a branched session's "current" row + // is its newest child leaf (/model, /branch) — resume THAT instead + // of the stale parent. Best-effort: on any failure (404 for a + // missing session, network blip) fall back to the requested id. + val effectiveId = resolveLatestDescendant(sessionId) ?: sessionId + val effectiveGeneration = + if (effectiveId != sessionId) { + resetSessionState(effectiveId, title, isLoading = true) + } else { + generation + } // Warm-cache fast-path (desktop parity): paint the cached Room // transcript immediately so the screen never sits blank, then load // the fresh server transcript in parallel. If the cache is empty // the spinner stays up until the server page lands. - loadCachedMessages(sessionId, generation) + loadCachedMessages(effectiveId, effectiveGeneration) // Resume the selected desktop session and hydrate its transcript. - resumeSession(sessionId, generation) + resumeSession(effectiveId, effectiveGeneration) loadSessions() } } + /** + * Resolve the newest descendant of a session (issue #787, desktop parity). + * A branched session continues in a child leaf; resuming the parent would + * open a stale branch. Returns null on any failure (sessions without + * descendants report `changed=false`; missing sessions 404) so callers + * fall back to the requested id. + */ + private suspend fun resolveLatestDescendant(sessionId: String): String? = + withContext(ioDispatcher) { + val result = + runCatching { + safeApiCall { ApiClient.hermesApi.getLatestDescendant(sessionId) } + }.getOrNull() + (result as? NetworkResult.Success)?.data + ?.takeIf { it.changed } + ?.session_id + } + private fun loadCachedMessages( sessionId: String, generation: Long, @@ -2144,6 +2173,11 @@ class ChatViewModel( } fun syncCurrentSession() { + // Issue #840: a session created but never prompted has no server row + // yet — the REST transcript 404s and the resume-retry machinery spins + // forever (visible in logcat as repeated /messages 404s). MessageStart + // flips the flag once the first prompt persists the row. + if (!sessionHasServerPresence) return val state = _uiState.value val sessionId = state.currentSessionId ?: return val generation = sessionGeneration @@ -2367,7 +2401,10 @@ class ChatViewModel( if (known != null) return known val result = withContext(ioDispatcher) { - safeApiCall { ApiClient.hermesApi.getSessions(limit = 500, offset = 0, order = "recent") } + // Backend caps limit at 100 (sessions.py Query le=100) — 500 + // 422'd (seen in device logcat after a branch). Sessions are + // ordered "recent", so the target is always in the top page. + safeApiCall { ApiClient.hermesApi.getSessions(limit = 100, offset = 0, order = "recent") } } if (result is NetworkResult.Success) { val sessions = result.data.sessions.orEmpty() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 3667cddb..d3643bf6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -43,6 +43,8 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material.icons.filled.Terminal import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -331,6 +333,29 @@ fun SessionsScreen( onClearToast = { viewModel.clearToast() }, ) + // Empty-session cleanup dialog (issue #787) + if (state.showEmptyCleanupDialog) { + AlertDialog( + onDismissRequest = { viewModel.hideEmptyCleanupDialog() }, + title = { Text(stringResource(R.string.sessions_empty_cleanup_title)) }, + text = { Text(stringResource(R.string.sessions_empty_cleanup_message, state.emptyCount)) }, + confirmButton = { + TextButton(onClick = { viewModel.confirmEmptyCleanup() }) { + if (state.isCleaningEmpty) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(R.string.sessions_empty_cleanup_confirm)) + } + } + }, + dismissButton = { + TextButton(onClick = { viewModel.hideEmptyCleanupDialog() }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + // Prune dialog if (state.showPruneDialog) { AlertDialog( @@ -517,6 +542,25 @@ fun SessionsScreen( }, ) } + Spacer(modifier = Modifier.width(spacing.sm)) + // Empty-session cleanup (issue #787) — grey/disabled when 0. + IconButton( + onClick = { viewModel.requestEmptyCleanup() }, + enabled = state.emptyCount > 0, + ) { + BadgedBox( + badge = { + if (state.emptyCount > 0) { + Badge { Text("${state.emptyCount}") } + } + }, + ) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = stringResource(R.string.sessions_empty_cleanup_desc), + ) + } + } } // List/state area takes all remaining height below the search bar. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index b50fd123..44f58213 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -43,6 +43,11 @@ data class SessionsUiState( val deletingSessionIds: Set = emptySet(), val showPruneDialog: Boolean = false, val isPruning: Boolean = false, + // Empty-session cleanup (issue #787): count of empty, ended, non-archived + // sessions — button is disabled/grey when 0. + val emptyCount: Int = 0, + val showEmptyCleanupDialog: Boolean = false, + val isCleaningEmpty: Boolean = false, val isDeletingBulk: Boolean = false, val toastMessage: String? = null, val sessionToDeleteConfirm: String? = null, @@ -98,6 +103,7 @@ class SessionsViewModel : /** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */ fun loadSessions() { + loadEmptyCount() loadJob = safeLaunchLoad( currentJob = loadJob, @@ -496,6 +502,68 @@ class SessionsViewModel : } } + // ── Empty-session cleanup (issue #787) ─────────────────────────────── + + /** Refresh the empty-session count (REST; auxiliary — failures are silent). */ + fun loadEmptyCount() { + viewModelScope.launch { + val result = + runCatching { + safeApiCall { ApiClient.hermesApi.getEmptySessionCount() } + }.getOrNull() + val count = (result as? NetworkResult.Success)?.data?.count + if (count != null) { + _uiState.update { it.copy(emptyCount = count) } + } + } + } + + fun requestEmptyCleanup() { + _uiState.update { it.copy(showEmptyCleanupDialog = true) } + } + + fun hideEmptyCleanupDialog() { + _uiState.update { it.copy(showEmptyCleanupDialog = false) } + } + + fun confirmEmptyCleanup() { + _uiState.update { it.copy(isCleaningEmpty = true, showEmptyCleanupDialog = false) } + viewModelScope.launch { + val result = + runCatching { + safeApiCall { ApiClient.hermesApi.deleteEmptySessions() } + }.getOrNull() + when (result) { + is NetworkResult.Success -> { + val deleted = result.data.deleted + _uiState.update { + it.copy( + isCleaningEmpty = false, + emptyCount = 0, + toastMessage = + if (deleted > 0) { + "Deleted $deleted empty sessions" + } else { + "No empty sessions to delete" + }, + ) + } + loadSessions() + } + + else -> { + val message = (result as? NetworkResult.Failure)?.error?.message ?: "Unknown error" + _uiState.update { + it.copy( + isCleaningEmpty = false, + toastMessage = "Cleanup failed: $message", + ) + } + } + } + } + } + // ── Toast ──────────────────────────────────────────────────────────── override fun clearToast() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 639e510a..92e13127 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -512,6 +512,10 @@ Delete sessions older than the specified number of days. Days threshold Prune + Delete empty sessions + Delete empty sessions? + Delete %1$d empty session(s). Active and archived sessions are kept. + Delete Delete Session? Are you sure you want to delete \"%1$s\"? This can\'t be undone. Delete Sessions? diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt index 79c0572d..48330033 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/SlashCommandDispatchRpcTest.kt @@ -142,11 +142,11 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() var captured = false every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { captured = true CompletableDeferred(Unit) @@ -157,8 +157,12 @@ class SlashCommandDispatchRpcTest { advanceUntilIdle() assertTrue("expected a COMMAND_DISPATCH request", captured) - assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) - val params = paramsSlot.captured + // Captures hold ALL requests — the create flow's session.usage / + // context_breakdown can land after the dispatch one (capture race, + // CI 2026-08-06 + 2026-08-07). Find the record, don't trust "last". + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val params = paramsCalls[dispatchIndex] assertEquals("help", params["name"]) assertEquals("", params["arg"]) assertEquals(sessionId, params["session_id"]) @@ -169,10 +173,10 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { CompletableDeferred(Unit) } @@ -180,8 +184,10 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/queue do the thing") advanceUntilIdle() - assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) - val params = paramsSlot.captured + // Find the dispatch record (capture race — see the /help test). + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val params = paramsCalls[dispatchIndex] assertEquals("queue", params["name"]) assertEquals("do the thing", params["arg"]) assertEquals(sessionId, params["session_id"]) @@ -218,12 +224,12 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { - val m = methodSlot.captured + val m = methodCalls.last() val d = CompletableDeferred() if (m == WsMethods.COMMAND_DISPATCH) { // Backend rejects /status with the registry-miss 4018 (issue #576). @@ -243,9 +249,14 @@ class SlashCommandDispatchRpcTest { advanceUntilIdle() // The fallback must have hit slash.exec with the full command string. - // (methodSlot/paramsSlot hold the LAST call = slash.exec.) - assertEquals(WsMethods.SLASH_EXEC, methodSlot.captured) - val params = paramsSlot.captured + // Captures hold ALL requests — find the records instead of trusting + // "last" (create-flow session.usage can land after; capture race). + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val execIndex = methodCalls.indexOf(WsMethods.SLASH_EXEC) + assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0) + assertTrue("dispatch must precede fallback", dispatchIndex < execIndex) + val params = paramsCalls[execIndex] assertEquals("/status", params["command"]) assertEquals(sessionId, params["session_id"]) @@ -261,11 +272,10 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, _) = createViewModelWithSession() - var rpcCalls = 0 + val rpcMethods = mutableListOf() every { - HermesWsClient.request(any(), any(), any()) + HermesWsClient.request(capture(rpcMethods), any(), any()) } answers { - rpcCalls++ CompletableDeferred(Unit) } @@ -281,7 +291,19 @@ class SlashCommandDispatchRpcTest { "expected a 'not supported on mobile' message, got: ${last?.content}", last?.content?.contains("not supported on mobile") == true, ) - assertEquals("no RPC should fire for a blocklisted command", 0, rpcCalls) + // No command RPC may fire for a blocklisted command. The create + // flow's session.usage/context_breakdown requests are unrelated + // noise that can land in the capture window (capture race) — the + // blocklist contract is about command dispatch, so assert on that. + val commandRpc = + rpcMethods.filter { + it == WsMethods.COMMAND_DISPATCH || it == WsMethods.SLASH_EXEC + } + assertEquals( + "no RPC should fire for a blocklisted command, got $rpcMethods", + emptyList(), + commandRpc, + ) } @Test @@ -364,10 +386,10 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { CompletableDeferred(Unit) } @@ -378,8 +400,10 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/init") advanceUntilIdle() - assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) - val params = paramsSlot.captured + // Find the dispatch record (capture race — see the /help test). + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val params = paramsCalls[dispatchIndex] assertEquals("init", params["name"]) assertEquals("", params["arg"]) assertEquals(sessionId, params["session_id"]) @@ -390,10 +414,10 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { CompletableDeferred(Unit) } @@ -401,8 +425,10 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/init extra context here") advanceUntilIdle() - assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) - val params = paramsSlot.captured + // Find the dispatch record (capture race — see the /help test). + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val params = paramsCalls[dispatchIndex] assertEquals("init", params["name"]) assertEquals("extra context here", params["arg"]) assertEquals(sessionId, params["session_id"]) @@ -413,10 +439,10 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { CompletableDeferred(Unit) } @@ -427,8 +453,10 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/focus") advanceUntilIdle() - assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) - val params = paramsSlot.captured + // Find the dispatch record (capture race — see the /help test). + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val params = paramsCalls[dispatchIndex] assertEquals("focus", params["name"]) assertEquals("", params["arg"]) assertEquals(sessionId, params["session_id"]) @@ -439,10 +467,10 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, sessionId) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { CompletableDeferred(Unit) } @@ -450,8 +478,10 @@ class SlashCommandDispatchRpcTest { vm.sendMessage("/focus on") advanceUntilIdle() - assertEquals(WsMethods.COMMAND_DISPATCH, methodSlot.captured) - val params = paramsSlot.captured + // Find the dispatch record (capture race — see the /help test). + val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH) + assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0) + val params = paramsCalls[dispatchIndex] assertEquals("focus", params["name"]) assertEquals("on", params["arg"]) assertEquals(sessionId, params["session_id"]) @@ -462,14 +492,14 @@ class SlashCommandDispatchRpcTest { runTest { val (vm, _) = createViewModelWithSession() - val methodSlot = slot() - val paramsSlot = slot>() + val methodCalls = mutableListOf() + val paramsCalls = mutableListOf>() val sentText = slot() every { - HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any()) + HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any()) } answers { val d = CompletableDeferred() - if (methodSlot.captured == WsMethods.COMMAND_DISPATCH) { + if (methodCalls.last() == WsMethods.COMMAND_DISPATCH) { // Backend /init returns type:"send" with the AGENTS.md prompt. d.complete(mapOf("type" to "send", "message" to "Scan this repo and write AGENTS.md")) } else {