mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 01:51:39 -06:00
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:
committed by
GitHub
parent
0a8226c3db
commit
1bf3e4e9b8
@@ -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
|
||||
|
||||
+45
@@ -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 {
|
||||
|
||||
+14
-10
@@ -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
|
||||
|
||||
+23
-1
@@ -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"}}}}"""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user