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:
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() {
|
||||
|
||||
@@ -512,6 +512,10 @@
|
||||
<string name="sessions_prune_desc">Delete sessions older than the specified number of days.</string>
|
||||
<string name="sessions_prune_days_label">Days threshold</string>
|
||||
<string name="sessions_prune_confirm">Prune</string>
|
||||
<string name="sessions_empty_cleanup_desc">Delete empty sessions</string>
|
||||
<string name="sessions_empty_cleanup_title">Delete empty sessions?</string>
|
||||
<string name="sessions_empty_cleanup_message">Delete %1$d empty session(s). Active and archived sessions are kept.</string>
|
||||
<string name="sessions_empty_cleanup_confirm">Delete</string>
|
||||
<string name="sessions_delete_title">Delete Session?</string>
|
||||
<string name="sessions_delete_message">Are you sure you want to delete \"%1$s\"? This can\'t be undone.</string>
|
||||
<string name="sessions_bulk_delete_title">Delete Sessions?</string>
|
||||
|
||||
@@ -142,11 +142,11 @@ class SlashCommandDispatchRpcTest {
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodSlot = slot<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
var captured = false
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
captured = true
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
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<Any?>()
|
||||
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<String>()
|
||||
every {
|
||||
HermesWsClient.request(any(), any(), any())
|
||||
HermesWsClient.request(capture(rpcMethods), any(), any())
|
||||
} answers {
|
||||
rpcCalls++
|
||||
CompletableDeferred<Any?>(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<String>(),
|
||||
commandRpc,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -364,10 +386,10 @@ class SlashCommandDispatchRpcTest {
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodSlot = slot<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
CompletableDeferred<Any?>(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<String>()
|
||||
val paramsSlot = slot<Map<String, Any>>()
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
val sentText = slot<String>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodSlot), capture(paramsSlot), any())
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
} answers {
|
||||
val d = CompletableDeferred<Any?>()
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user