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

@@ -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 {