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