fix(android): gate gateway RPC polling on the hello method catalog (#126540)

* fix(android): gate gateway RPC polling on the hello method catalog

Released 2026.7.x gateways authorize before dispatch and reject unknown
methods with "missing scope: operator.admin", so the app's
"unknown method: X" detectors never fired: outbox sends parked forever
behind an ~800ms sessions.branches.list retry loop and question.list
retried on every health event. Generalize the progress-card negotiation
(3377a21c4e) into a tri-state gatewayAdvertisesMethod seam fed by
hello features.methods and skip sessions.branches.list, question.list,
and progressCard.get when the gateway does not advertise them; branch
scopes reconcile immediately and queued sends flush.

* fix(android): keep the hello method catalog unknown when hello omits features.methods

A successful connect without a usable features.methods list must not read
as a known-empty catalog: parse it as null so gatewayAdvertisesMethod stays
tri-state and the catalog gates no-op instead of skipping documented RPCs.
Pairing capabilities keep positive-advertisement semantics via orEmpty().
Addresses the ClawSweeper P1 on #126540.
This commit is contained in:
Peter Steinberger
2026-08-19 22:56:15 -07:00
committed by GitHub
parent 0a8226c3db
commit 1bf3e4e9b8
8 changed files with 155 additions and 45 deletions
@@ -1326,7 +1326,7 @@ class NodeRuntime private constructor(
// response from publishing into a replacement socket on the same stable endpoint.
private val gatewayMethodsLock = Any()
private var gatewayApprovalRpcFamily = GatewayApprovalRpcFamily.Unavailable
private var gatewayProgressCardAdvertised: Boolean? = null
private var gatewayAdvertisedMethods: Set<String>? = null
private var gatewayMethodsEpoch = 0L
@Volatile internal var gatewayDataRequestOverrideForTests: GatewayDataRequestOverride? = null
@@ -1404,8 +1404,9 @@ class NodeRuntime private constructor(
replaceGatewayMethods(hello.methods)
val operatorScopes = normalizeOperatorScopes(hello.authScopes)
_operatorScopes.value = operatorScopes
// Pairing capabilities require positive hello advertisement; an unknown catalog grants none.
_devicePairingCapabilities.value =
selectGatewayDevicePairingCapabilities(hello.methods, operatorScopes)
selectGatewayDevicePairingCapabilities(hello.methods.orEmpty(), operatorScopes)
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
val mainSessionKey =
prepareMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
@@ -1918,7 +1919,7 @@ class NodeRuntime private constructor(
cacheScope = ::chatCacheScope,
currentDefaultAgentId = { gatewayDefaultAgentId.value },
currentDefaultAgentRevision = gatewayDefaultAgentRevision::get,
gatewayAdvertisesProgressCard = ::gatewayAdvertisesProgressCard,
gatewayAdvertisesMethod = ::gatewayAdvertisesMethod,
commandOutbox = chatCommandOutbox,
recordModelRecent = prefs::recordModelRecent,
onSessionDeleted = ::publishChatSessionDeletion,
@@ -1934,7 +1935,7 @@ class NodeRuntime private constructor(
scope = scope,
json = json,
requestGateway = AndroidScreenshotFixture::request,
gatewayAdvertisesProgressCard = { true },
gatewayAdvertisesMethod = { _ -> true },
)
}.also {
it.applyMainSessionKey(_mainSessionKey.value)
@@ -7400,8 +7401,8 @@ class NodeRuntime private constructor(
private fun replaceGatewayMethods(methods: Set<String>?) {
synchronized(gatewayMethodsLock) {
val advertisedMethods = methods.orEmpty()
gatewayAdvertisedMethods = methods
gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(advertisedMethods)
gatewayProgressCardAdvertised = methods?.let { GatewayMethod.ProgressCardGet.rawValue in it }
_clawHubSkillMethodsAvailable.value = supportsClawHubSkillManagement(advertisedMethods)
_desktopObserveAvailable.value = GatewayMethod.DesktopObserve.rawValue in advertisedMethods
systemAgentChatSupported.value = GatewayMethod.OpenclawChat.rawValue in advertisedMethods
@@ -7409,7 +7410,7 @@ class NodeRuntime private constructor(
}
}
private fun gatewayAdvertisesProgressCard(): Boolean? = synchronized(gatewayMethodsLock) { gatewayProgressCardAdvertised }
private fun gatewayAdvertisesMethod(method: String): Boolean? = synchronized(gatewayMethodsLock) { gatewayAdvertisedMethods?.let { method in it } }
private fun captureGatewayMethods(): GatewayMethodsSnapshot =
synchronized(gatewayMethodsLock) {
@@ -101,6 +101,8 @@ private class MainSessionReadiness(
var job: Job? = null
}
private class BranchListingUnsupportedException : IllegalStateException("sessions.branches.list is not supported by this gateway")
class ChatController internal constructor(
private val scope: CoroutineScope,
private val json: Json,
@@ -109,7 +111,7 @@ class ChatController internal constructor(
{ method, paramsJson, _ -> requestGateway(method, paramsJson) },
private val requestGatewayForGateway: suspend (gatewayId: String, method: String, paramsJson: String?) -> String =
{ _, method, paramsJson -> requestGateway(method, paramsJson) },
private val gatewayAdvertisesProgressCard: () -> Boolean? = { null },
private val gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
private val captureSettingsRequestLease: (gatewayScope: ChatCacheScope?) -> GatewaySession.RequestLease? =
{ gatewayScope ->
GatewaySession.RequestLease(endpointStableId = gatewayScope?.gatewayId.orEmpty()) { method, paramsJson, _ ->
@@ -152,7 +154,7 @@ class ChatController internal constructor(
cacheScope: () -> ChatCacheScope? = { null },
currentDefaultAgentId: () -> String? = { "main" },
currentDefaultAgentRevision: () -> Long = { 0L },
gatewayAdvertisesProgressCard: () -> Boolean? = { null },
gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
commandOutbox: ChatCommandOutbox? = null,
recordModelRecent: (String) -> Unit = {},
onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
@@ -168,7 +170,7 @@ class ChatController internal constructor(
requestGatewayForGateway = { gatewayId, method, paramsJson ->
session.requestForEndpoint(gatewayId, method, paramsJson)
},
gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard,
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
captureSettingsRequestLease = { gatewayScope ->
session.captureRequestLease(gatewayScope?.gatewayId)
},
@@ -1772,6 +1774,7 @@ class ChatController internal constructor(
sessionKey: String,
ownerAgentId: String,
): List<SessionBranch> {
if (gatewayAdvertisesMethod("sessions.branches.list") == false) throw BranchListingUnsupportedException()
val params =
buildJsonObject {
put("sessionKey", JsonPrimitive(sessionKey))
@@ -1914,7 +1917,9 @@ class ChatController internal constructor(
}
}
private fun branchListingUnsupported(error: Throwable): Boolean = error.message?.contains("unknown method: sessions.branches.list", ignoreCase = true) == true
private fun branchListingUnsupported(error: Throwable): Boolean =
error is BranchListingUnsupportedException ||
error.message?.contains("unknown method: sessions.branches.list", ignoreCase = true) == true
private suspend fun refreshHistoryForSessionAction(
snapshot: SessionActionSnapshot,
@@ -3409,27 +3414,34 @@ class ChatController internal constructor(
gatewayScope: ChatCacheScope?,
): Boolean {
val response =
try {
requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}")
} catch (err: GatewayRequestRejected) {
val unavailable =
err.gatewayError.missingScope() == "operator.questions" ||
(
err.gatewayError.code == "INVALID_REQUEST" &&
err.gatewayError.message == "unknown method: question.list"
)
if (!unavailable) throw err
if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false
return synchronized(questionStateLock) {
if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return@synchronized false
if (_questions.value.isNotEmpty()) {
_questions.value = emptyList()
questionStateRevision += 1
}
syncQuestionEvictionsLocked()
true
if (gatewayAdvertisesMethod("question.list") == false) {
null
} else {
try {
requestGatewayBound(gatewayScope?.gatewayId, "question.list", "{}")
} catch (err: GatewayRequestRejected) {
val unavailable =
err.gatewayError.missingScope() == "operator.questions" ||
(
err.gatewayError.code == "INVALID_REQUEST" &&
err.gatewayError.message == "unknown method: question.list"
)
if (!unavailable) throw err
null
}
}
if (response == null) {
if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false
return synchronized(questionStateLock) {
if (!questionRefreshIsCurrentLocked(refreshGeneration, stateRevision)) return@synchronized false
if (_questions.value.isNotEmpty()) {
_questions.value = emptyList()
questionStateRevision += 1
}
syncQuestionEvictionsLocked()
true
}
}
if (!questionRefreshIsCurrent(refreshGeneration, stateRevision, gatewayScope)) return false
val listedRecords = json.decodeFromString<QuestionListResult>(response).questions
val listedIds = listedRecords.mapTo(mutableSetOf()) { it.id }
@@ -5889,7 +5901,7 @@ class ChatController internal constructor(
// SUNSET 2026-10-18: this fallback is a fixed cutover window, not a permanent contract.
// On that date delete it together with the Gateway's legacy stream:"plan" dual-emit and
// the Apple twin in ChatViewModel+TransportEvents.swift. Tracked: #125639.
if (gatewayAdvertisesProgressCard() != false) return
if (gatewayAdvertisesMethod("progressCard.get") != false) return
val planData = data ?: return
if (planData["phase"].asStringOrNull() != "update") return
val steps = parseChatPlanSteps(planData["steps"])
@@ -6098,6 +6110,7 @@ class ChatController internal constructor(
}
private fun refreshProgressCard() {
if (gatewayAdvertisesMethod("progressCard.get") == false) return
val sessionKey = normalizeRequestedSessionKey(_sessionKey.value)
val gatewayScope = currentCacheScope()
val generation = progressCardFetchGeneration.incrementAndGet()
@@ -214,7 +214,7 @@ data class GatewayHelloSummary(
val updateAvailable: GatewayUpdateAvailableSummary?,
val authRole: String? = null,
val authScopes: List<String> = emptyList(),
val methods: Set<String> = emptySet(),
val methods: Set<String>? = null,
)
data class GatewayUpdateAvailableSummary(
@@ -1441,7 +1441,6 @@ class GatewaySession(
.asArrayOrNull()
?.mapNotNull { it.asStringOrNull()?.trim()?.takeIf { method -> method.isNotEmpty() } }
?.toSet()
.orEmpty()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
@@ -1,6 +1,8 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewayRequestOutcomeUnknown
import ai.openclaw.app.gateway.GatewayRequestRejected
import ai.openclaw.app.gateway.GatewaySession
import androidx.room.Room
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
@@ -53,6 +55,7 @@ class ChatControllerBranchCoordinationTest {
private fun controller(
gateway: ScriptedGateway,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
): ChatController {
val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
controllerScopes += controllerScope
@@ -61,6 +64,7 @@ class ChatControllerBranchCoordinationTest {
json = json,
requestGateway = gateway::request,
cacheScope = { ChatCacheScope("gateway-a", 1) },
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
commandOutbox = outbox,
)
}
@@ -261,6 +265,47 @@ class ChatControllerBranchCoordinationTest {
assertFalse(outbox.branchState("gateway-a", ChatOutboxScope("main", "main"))?.needsReconciliation == true)
}
@Test
fun gatewayWithoutBranchListingDispatchesQueuedInputWithoutRequestingBranches() =
runTest {
val gateway = ScriptedGateway(json)
gateway.respondWith("chat.history", historyResponse(sessionId = "main", messages = emptyList()))
gateway.respond("sessions.branches.list") {
throw GatewayRequestRejected(
GatewaySession.ErrorShape(
code = "INVALID_REQUEST",
message = "missing scope: operator.admin",
),
)
}
gateway.respondChatSend("started")
val controller =
controller(
gateway,
StandardTestDispatcher(testScheduler),
gatewayAdvertisesMethod = { method -> method != "sessions.branches.list" },
)
runCurrent()
controller.awaitOutboxRestore()
controller.handleGatewayEvent("health", null)
runCurrent()
assertTrue(controller.healthOk.value)
assertTrue(controller.sendMessageAwaitAcceptance("dispatch without branches", "off", emptyList()))
withContext(Dispatchers.Default.limitedParallelism(1)) {
withTimeout(5_000) {
while (gateway.callCount("chat.send") == 0 && gateway.callCount("sessions.branches.list") == 0) {
runCurrent()
kotlinx.coroutines.delay(10)
}
}
}
assertEquals(0, gateway.callCount("sessions.branches.list"))
assertEquals(1, gateway.callCount("chat.send"))
assertFalse(outbox.load("gateway-a").single().status == ChatOutboxStatus.Queued)
}
@Test
fun expiredMutationLeaseReconcilesBeforeStartingTheNextAction() =
runTest {
@@ -20,17 +20,20 @@ class ChatControllerProgressCardTest {
private fun TestScope.newController(
gateway: ScriptedGateway,
gatewayAdvertisesProgressCard: () -> Boolean? = { null },
gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
): ChatController =
backgroundScope.createChatController(
requestGateway = gateway::request,
gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard,
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
)
private suspend fun TestScope.startRun(gatewayAdvertisesProgressCard: Boolean?): StartedRun {
private suspend fun TestScope.startRun(progressCardAdvertised: Boolean?): StartedRun {
val gateway = ScriptedGateway(chatControllerTestJson)
gateway.respondChatSend(status = "started")
val controller = newController(gateway) { gatewayAdvertisesProgressCard }
val controller =
newController(gateway) { method ->
if (method == "progressCard.get") progressCardAdvertised else true
}
controller.handleGatewayEvent("health", null)
runCurrent()
assertTrue(controller.sendMessageAwaitAcceptance("make a plan", "off", emptyList()))
@@ -59,7 +62,7 @@ class ChatControllerProgressCardTest {
@Test
fun legacyPlanRendersWhenGatewayLacksProgressCardStore() =
runTest {
val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = false)
val (controller, _, runId) = startRun(progressCardAdvertised = false)
controller.handleGatewayEvent(
"agent",
@@ -88,7 +91,7 @@ class ChatControllerProgressCardTest {
@Test
fun emptyLegacyPlanClearsFallbackCard() =
runTest {
val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = false)
val (controller, _, runId) = startRun(progressCardAdvertised = false)
controller.handleGatewayEvent(
"agent",
planEvent(runId, """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}"""),
@@ -109,7 +112,7 @@ class ChatControllerProgressCardTest {
@Test
fun capableGatewayIgnoresLegacyPlanDualEmit() =
runTest {
val (controller, gateway, runId) = startRun(gatewayAdvertisesProgressCard = true)
val (controller, gateway, runId) = startRun(progressCardAdvertised = true)
gateway.respondWith("progressCard.get", cardResponse(markdown = "Canonical"))
controller.handleGatewayEvent("progressCard.changed", changedEvent("main", "1"))
runCurrent()
@@ -126,7 +129,7 @@ class ChatControllerProgressCardTest {
@Test
fun unknownGatewayCapabilityIgnoresLegacyPlan() =
runTest {
val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = null)
val (controller, _, runId) = startRun(progressCardAdvertised = null)
controller.handleGatewayEvent(
"agent",
@@ -137,9 +140,9 @@ class ChatControllerProgressCardTest {
}
@Test
fun failedStoreFetchPreservesLegacyFallbackCard() =
fun healthRefreshSkipsUnadvertisedStoreAndPreservesLegacyFallbackCard() =
runTest {
val (controller, gateway, runId) = startRun(gatewayAdvertisesProgressCard = false)
val (controller, gateway, runId) = startRun(progressCardAdvertised = false)
controller.handleGatewayEvent(
"agent",
planEvent(runId, """{"phase":"update","explanation":"Keep me","steps":[{"step":"Active","status":"in_progress"}]}"""),
@@ -151,6 +154,7 @@ class ChatControllerProgressCardTest {
runCurrent()
assertEquals(expected, controller.progressCard.value)
assertEquals(0, gateway.callCount("progressCard.get"))
}
@Test
@@ -178,6 +178,30 @@ class ChatQuestionTest {
assertTrue(controller.questions.value.isEmpty())
}
@Test
fun gatewayWithoutQuestionListClearsStaleCardsWithoutRequestingQuestions() =
runTest {
val (controller, requests) =
chatControllerTestSetup {
gatewayAdvertisesMethod = { method -> method != "question.list" }
respond("question.list") {
throw GatewayRequestRejected(
GatewaySession.ErrorShape(
code = "INVALID_REQUEST",
message = "missing scope: operator.admin",
),
)
}
}
controller.handleGatewayEvent("question.requested", json.encodeToString(record(id = "ask_stale")))
controller.handleGatewayEvent("health", null)
advanceUntilIdle()
assertTrue(requests.none { it.first == "question.list" })
assertTrue(controller.questions.value.isEmpty())
}
@Test
fun pendingRefreshPreservesSubmissionLock() =
runTest {
@@ -19,7 +19,7 @@ internal fun CoroutineScope.createChatController(
cacheScope: () -> ChatCacheScope? = { null },
currentDefaultAgentId: () -> String? = { "main" },
currentDefaultAgentRevision: () -> Long = { 0L },
gatewayAdvertisesProgressCard: () -> Boolean? = { null },
gatewayAdvertisesMethod: (method: String) -> Boolean? = { null },
recordModelRecent: (String) -> Unit = {},
onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
onOfflineDefaultAgentRestored: (String) -> Unit = {},
@@ -48,7 +48,7 @@ internal fun CoroutineScope.createChatController(
cacheScope = cacheScope,
currentDefaultAgentId = currentDefaultAgentId,
currentDefaultAgentRevision = currentDefaultAgentRevision,
gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard,
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
recordModelRecent = recordModelRecent,
onSessionDeleted = onSessionDeleted,
onOfflineDefaultAgentRestored = onOfflineDefaultAgentRestored,
@@ -61,6 +61,7 @@ internal class ChatControllerTestSetup(
) {
val requests = mutableListOf<Pair<String, String?>>()
var cacheScope: () -> ChatCacheScope? = { null }
var gatewayAdvertisesMethod: (method: String) -> Boolean? = { null }
var recordModelRecent: (String) -> Unit = {}
private val handlers = mutableMapOf<String, suspend (String?) -> String>()
@@ -82,6 +83,7 @@ internal class ChatControllerTestSetup(
val controller: ChatController by lazy {
scope.createChatController(
cacheScope = cacheScope,
gatewayAdvertisesMethod = gatewayAdvertisesMethod,
recordModelRecent = recordModelRecent,
requestGateway = { method, paramsJson ->
requests += method to paramsJson
@@ -238,6 +238,25 @@ class GatewaySessionReconnectTest {
}
}
@Test
fun connectedHelloKeepsMethodCatalogUnknownWhenHelloOmitsFeatures() =
runBlocking {
val json = Json { ignoreUnknownKeys = true }
val hello = CompletableDeferred<GatewayHelloSummary>()
val server =
startGatewayServer(json = json) { webSocket, id, method ->
if (method == "connect") webSocket.send(connectResponseFrame(id, methods = null))
}
val harness = createReconnectHarness(onHello = hello::complete)
try {
connectNodeSession(harness.session, server.port)
assertNull(withTimeout(LIFECYCLE_TEST_TIMEOUT_MS) { hello.await() }.methods)
} finally {
shutdownReconnectHarness(harness, server)
}
}
@Test
fun disconnectAndJoinWaitsForNaturalFailureCallback() =
runBlocking {
@@ -1100,8 +1119,11 @@ class GatewaySessionReconnectTest {
private fun connectResponseFrame(
id: String,
methods: Set<String> = emptySet(),
methods: Set<String>? = emptySet(),
): String {
if (methods == null) {
return """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
}
val encodedMethods = methods.joinToString(",") { JsonPrimitive(it).toString() }
return """{"type":"res","id":"$id","ok":true,"payload":{"features":{"methods":[$encodedMethods]},"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
}