feat(sessions): empty-session cleanup + latest-descendant resume + fresh-session 404 fix (#787, #840) (#841)

* feat(sessions): empty-session cleanup button + latest-descendant resume + fresh-session 404 fix (issues #787, #840)

- Sessions top bar: Delete-empty icon with count badge, grey/disabled when 0
  (GET /api/sessions/empty/count + DELETE /api/sessions/empty, confirm dialog)
- Resume flow resolves GET /api/sessions/{id}/latest-descendant first —
  branched sessions continue in the newest child leaf, not the stale parent
  (best-effort; falls back to the requested id)
- syncCurrentSession guarded by sessionHasServerPresence — a freshly created
  (never prompted) session has no server row yet; the REST transcript 404s
  and the resume retry spun forever (logcat 404 evidence)

* fix(sessions): clamp message-count fallback to backend limit cap (422)

fetchServerMessageCount requested limit=500 but the backend caps
session list page size at 100 (sessions.py Query le=100) — every
fallback scan 422'd (seen in device logcat after a branch). Sessions
are ordered recent, so the target is always in the top page.

* test(slash): make dispatch-capture assertions order-independent (CI flake)

The capture-race flake (SlashCommandDispatchRpcTest, hit 3x across CI and
local runs 2026-08-07): tests asserted on methodSlot.captured — the LAST
request captured — but the create flow's session.usage/context_breakdown
requests can land after the slash-dispatch one depending on suite order
(the class's own comment documents this cross-wiring). Converted the
three capture-based tests to capture ALL requests and find the target
record by method, with dispatch-before-fallback ordering for the 4018
test. Same assertions, no dependence on request interleaving.

* test(slash): convert remaining capture-race assertions (flake round 2)

The capture race moved to the focus/init tests (:424) after the first
three were fixed — every request-capture test in the class used the
same last-captured assumption. Converted the remaining five (init,
init+arg, focus, focus on, type=send) to the find-record-by-method
pattern; the type=send answers block reads methodCalls.last() since the
capture precedes the answer. Zero request() slot captures remain.

* test(slash): blocklist assertion counts command RPCs only (flake round 3)

The blocklisted-command test counted ALL request() calls and asserted
zero — the create flow's session.usage/context_breakdown noise can land
in the window after the stub registers (the same capture race that hit
the dispatch tests). Asserted instead that no COMMAND_DISPATCH or
SLASH_EXEC fires, which is the actual blocklist contract. E2E's static
Dispatchers mock stays (E2E VMs need IO routed to the test dispatcher —
removing it broke 20 E2E tests).

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 21:36:23 +03:00
1 parent 7998248507
commit f20b50b48f
7 files changed
+274 -48

No files matched your search

@@ -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<String> = emptyList(),
val changed: Boolean = false,
)
@@ -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<Unit>
// ── Admin: Empty-session cleanup + branch resolution (issue #787) ──
@GET("api/sessions/empty/count")
suspend fun getEmptySessionCount(): Response<EmptySessionsCountResponse>
@DELETE("api/sessions/empty")
suspend fun deleteEmptySessions(): Response<EmptySessionsDeleteResponse>
@GET("api/sessions/{id}/latest-descendant")
suspend fun getLatestDescendant(
@Path("id", encoded = true) sessionId: String,
): Response<LatestDescendantResponse>
@GET("api/sessions/{id}/prompt")
suspend fun getSessionPrompt(
// Preserve slashes in session IDs — backend generates IDs containing '/' characters (issue #468).
@@ -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()
@@ -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.
@@ -43,6 +43,11 @@ data class SessionsUiState(
val deletingSessionIds: Set<String> = 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() {