mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 17:41:33 -06:00
fix(android): render legacy plan events when the gateway lacks the progress-card store
Released gateways through v2026.7.x emit stream:"plan" events and do not advertise progressCard.get, so retain a negotiated Android fallback. Remove this branch with the gateway legacy dual-emit after the minimum supported gateway ships the progress-card store.
This commit is contained in:
@@ -1354,6 +1354,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 gatewayMethodsEpoch = 0L
|
||||
|
||||
@Volatile internal var gatewayDataRequestOverrideForTests: GatewayDataRequestOverride? = null
|
||||
@@ -1703,7 +1704,7 @@ class NodeRuntime private constructor(
|
||||
_remoteAddress.value = null
|
||||
_gatewayVersion.value = null
|
||||
_gatewayUpdateAvailable.value = null
|
||||
replaceGatewayMethods(emptySet())
|
||||
replaceGatewayMethods(null)
|
||||
_operatorScopes.value = emptyList()
|
||||
_devicePairingCapabilities.value = GatewayDevicePairingCapabilities()
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
@@ -1959,6 +1960,7 @@ class NodeRuntime private constructor(
|
||||
cacheScope = ::chatCacheScope,
|
||||
currentDefaultAgentId = { gatewayDefaultAgentId.value },
|
||||
currentDefaultAgentRevision = gatewayDefaultAgentRevision::get,
|
||||
gatewayAdvertisesProgressCard = ::gatewayAdvertisesProgressCard,
|
||||
commandOutbox = chatCommandOutbox,
|
||||
recordModelRecent = prefs::recordModelRecent,
|
||||
onSessionDeleted = ::publishChatSessionDeletion,
|
||||
@@ -1974,6 +1976,7 @@ class NodeRuntime private constructor(
|
||||
scope = scope,
|
||||
json = json,
|
||||
requestGateway = AndroidScreenshotFixture::request,
|
||||
gatewayAdvertisesProgressCard = { true },
|
||||
)
|
||||
}.also {
|
||||
it.applyMainSessionKey(_mainSessionKey.value)
|
||||
@@ -7644,16 +7647,20 @@ class NodeRuntime private constructor(
|
||||
?: error("Malformed approval.get response")
|
||||
}
|
||||
|
||||
private fun replaceGatewayMethods(methods: Set<String>) {
|
||||
private fun replaceGatewayMethods(methods: Set<String>?) {
|
||||
synchronized(gatewayMethodsLock) {
|
||||
gatewayApprovalRpcFamily = selectGatewayApprovalRpcFamily(methods)
|
||||
_clawHubSkillMethodsAvailable.value = supportsClawHubSkillManagement(methods)
|
||||
_desktopObserveAvailable.value = GatewayMethod.DesktopObserve.rawValue in methods
|
||||
systemAgentChatSupported.value = GatewayMethod.OpenclawChat.rawValue in methods
|
||||
val advertisedMethods = methods.orEmpty()
|
||||
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
|
||||
gatewayMethodsEpoch += 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun gatewayAdvertisesProgressCard(): Boolean? = synchronized(gatewayMethodsLock) { gatewayProgressCardAdvertised }
|
||||
|
||||
private fun captureGatewayMethods(): GatewayMethodsSnapshot =
|
||||
synchronized(gatewayMethodsLock) {
|
||||
GatewayMethodsSnapshot(
|
||||
|
||||
@@ -52,6 +52,7 @@ import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
// Bounds one-shot search list fetches like the primary session list.
|
||||
@@ -108,6 +109,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 captureSettingsRequestLease: (gatewayScope: ChatCacheScope?) -> GatewaySession.RequestLease? =
|
||||
{ gatewayScope ->
|
||||
GatewaySession.RequestLease(endpointStableId = gatewayScope?.gatewayId.orEmpty()) { method, paramsJson, _ ->
|
||||
@@ -150,6 +152,7 @@ class ChatController internal constructor(
|
||||
cacheScope: () -> ChatCacheScope? = { null },
|
||||
currentDefaultAgentId: () -> String? = { "main" },
|
||||
currentDefaultAgentRevision: () -> Long = { 0L },
|
||||
gatewayAdvertisesProgressCard: () -> Boolean? = { null },
|
||||
commandOutbox: ChatCommandOutbox? = null,
|
||||
recordModelRecent: (String) -> Unit = {},
|
||||
onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
|
||||
@@ -165,6 +168,7 @@ class ChatController internal constructor(
|
||||
requestGatewayForGateway = { gatewayId, method, paramsJson ->
|
||||
session.requestForEndpoint(gatewayId, method, paramsJson)
|
||||
},
|
||||
gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard,
|
||||
captureSettingsRequestLease = { gatewayScope ->
|
||||
session.captureRequestLease(gatewayScope?.gatewayId)
|
||||
},
|
||||
@@ -575,6 +579,7 @@ class ChatController internal constructor(
|
||||
// Drops stale history responses after session switches or refresh races.
|
||||
private val historyLoadGeneration = AtomicLong(0)
|
||||
private val progressCardFetchGeneration = AtomicLong(0)
|
||||
private val legacyProgressCardRevision = AtomicInteger(0)
|
||||
|
||||
// Advances when the visible session changes. Sends use it to detect A -> B -> A switches
|
||||
// across durable outbox suspension points; same-owner history reloads keep their projection.
|
||||
@@ -5879,6 +5884,26 @@ class ChatController internal constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
"plan" -> {
|
||||
// Released Gateways through v2026.7.x only emit stream:"plan" and lack progressCard.get.
|
||||
// Remove this fallback with the Gateway's legacy dual-emit once the minimum supported
|
||||
// Gateway ships the store (tracked follow-up).
|
||||
if (gatewayAdvertisesProgressCard() != false) return
|
||||
val planData = data ?: return
|
||||
if (planData["phase"].asStringOrNull() != "update") return
|
||||
val steps = parseChatPlanSteps(planData["steps"])
|
||||
if (steps.isEmpty()) {
|
||||
clearProgressCard(clearScopeKey = false)
|
||||
return
|
||||
}
|
||||
_progressCard.value =
|
||||
ChatProgressCard(
|
||||
revision = legacyProgressCardRevision.incrementAndGet(),
|
||||
updatedAt = payload["ts"].asLongOrNull() ?: 0L,
|
||||
markdown = planData["explanation"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
|
||||
steps = steps,
|
||||
)
|
||||
}
|
||||
"error" -> {
|
||||
updateLocalizedErrorText(nativeText("Event stream interrupted; try refreshing."))
|
||||
if (runId == null) {
|
||||
|
||||
+127
-1
@@ -12,7 +12,36 @@ import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatControllerProgressCardTest {
|
||||
private fun TestScope.newController(gateway: ScriptedGateway): ChatController = backgroundScope.createChatController(requestGateway = gateway::request)
|
||||
private data class StartedRun(
|
||||
val controller: ChatController,
|
||||
val gateway: ScriptedGateway,
|
||||
val runId: String,
|
||||
)
|
||||
|
||||
private fun TestScope.newController(
|
||||
gateway: ScriptedGateway,
|
||||
gatewayAdvertisesProgressCard: () -> Boolean? = { null },
|
||||
): ChatController =
|
||||
backgroundScope.createChatController(
|
||||
requestGateway = gateway::request,
|
||||
gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard,
|
||||
)
|
||||
|
||||
private suspend fun TestScope.startRun(gatewayAdvertisesProgressCard: Boolean?): StartedRun {
|
||||
val gateway = ScriptedGateway(chatControllerTestJson)
|
||||
gateway.respondChatSend(status = "started")
|
||||
val controller = newController(gateway) { gatewayAdvertisesProgressCard }
|
||||
controller.handleGatewayEvent("health", null)
|
||||
runCurrent()
|
||||
assertTrue(controller.sendMessageAwaitAcceptance("make a plan", "off", emptyList()))
|
||||
return StartedRun(controller, gateway, requireNotNull(gateway.lastRunId))
|
||||
}
|
||||
|
||||
private fun planEvent(
|
||||
runId: String,
|
||||
data: String,
|
||||
timestamp: Long = 10,
|
||||
): String = """{"sessionKey":"main","runId":"$runId","seq":1,"ts":$timestamp,"stream":"plan","data":$data}"""
|
||||
|
||||
private fun changedEvent(
|
||||
sessionKey: String,
|
||||
@@ -27,6 +56,103 @@ class ChatControllerProgressCardTest {
|
||||
steps: String = "[]",
|
||||
): String = """{"card":{"sessionKey":"$sessionKey","revision":$revision,"updatedAt":$updatedAt,"markdown":"$markdown","steps":$steps}}"""
|
||||
|
||||
@Test
|
||||
fun legacyPlanRendersWhenGatewayLacksProgressCardStore() =
|
||||
runTest {
|
||||
val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = false)
|
||||
|
||||
controller.handleGatewayEvent(
|
||||
"agent",
|
||||
planEvent(
|
||||
runId,
|
||||
"""{"phase":"update","explanation":" Inspect, patch, and test ","steps":[{"step":" Inspect ","status":"completed"},{"step":"Patch","status":"in_progress"},{"step":"Test","status":"pending"}]}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
ChatProgressCard(
|
||||
revision = 1,
|
||||
updatedAt = 10,
|
||||
markdown = "Inspect, patch, and test",
|
||||
steps =
|
||||
listOf(
|
||||
ChatPlanStep("Inspect", ChatPlanStepStatus.Completed),
|
||||
ChatPlanStep("Patch", ChatPlanStepStatus.InProgress),
|
||||
ChatPlanStep("Test", ChatPlanStepStatus.Pending),
|
||||
),
|
||||
),
|
||||
controller.progressCard.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyLegacyPlanClearsFallbackCard() =
|
||||
runTest {
|
||||
val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = false)
|
||||
controller.handleGatewayEvent(
|
||||
"agent",
|
||||
planEvent(runId, """{"phase":"update","steps":[{"step":"Active","status":"in_progress"}]}"""),
|
||||
)
|
||||
assertEquals(
|
||||
"Active",
|
||||
controller.progressCard.value
|
||||
?.steps
|
||||
?.single()
|
||||
?.step,
|
||||
)
|
||||
|
||||
controller.handleGatewayEvent("agent", planEvent(runId, """{"phase":"update","steps":[]}"""))
|
||||
|
||||
assertNull(controller.progressCard.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capableGatewayIgnoresLegacyPlanDualEmit() =
|
||||
runTest {
|
||||
val (controller, gateway, runId) = startRun(gatewayAdvertisesProgressCard = true)
|
||||
gateway.respondWith("progressCard.get", cardResponse(markdown = "Canonical"))
|
||||
controller.handleGatewayEvent("progressCard.changed", changedEvent("main", "1"))
|
||||
runCurrent()
|
||||
val expected = requireNotNull(controller.progressCard.value)
|
||||
|
||||
controller.handleGatewayEvent(
|
||||
"agent",
|
||||
planEvent(runId, """{"phase":"update","explanation":"Legacy","steps":[{"step":"Duplicate","status":"in_progress"}]}"""),
|
||||
)
|
||||
|
||||
assertEquals(expected, controller.progressCard.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownGatewayCapabilityIgnoresLegacyPlan() =
|
||||
runTest {
|
||||
val (controller, _, runId) = startRun(gatewayAdvertisesProgressCard = null)
|
||||
|
||||
controller.handleGatewayEvent(
|
||||
"agent",
|
||||
planEvent(runId, """{"phase":"update","steps":[{"step":"Wait","status":"in_progress"}]}"""),
|
||||
)
|
||||
|
||||
assertNull(controller.progressCard.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedStoreFetchPreservesLegacyFallbackCard() =
|
||||
runTest {
|
||||
val (controller, gateway, runId) = startRun(gatewayAdvertisesProgressCard = false)
|
||||
controller.handleGatewayEvent(
|
||||
"agent",
|
||||
planEvent(runId, """{"phase":"update","explanation":"Keep me","steps":[{"step":"Active","status":"in_progress"}]}"""),
|
||||
)
|
||||
val expected = requireNotNull(controller.progressCard.value)
|
||||
gateway.respond("progressCard.get") { error("method not found") }
|
||||
|
||||
controller.handleGatewayEvent("health", null)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(expected, controller.progressCard.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchingChangeFetchesAndPublishesTypedCard() =
|
||||
runTest {
|
||||
|
||||
@@ -19,6 +19,7 @@ internal fun CoroutineScope.createChatController(
|
||||
cacheScope: () -> ChatCacheScope? = { null },
|
||||
currentDefaultAgentId: () -> String? = { "main" },
|
||||
currentDefaultAgentRevision: () -> Long = { 0L },
|
||||
gatewayAdvertisesProgressCard: () -> Boolean? = { null },
|
||||
recordModelRecent: (String) -> Unit = {},
|
||||
onSessionDeleted: (ChatSessionDeletion) -> Unit = {},
|
||||
onOfflineDefaultAgentRestored: (String) -> Unit = {},
|
||||
@@ -47,6 +48,7 @@ internal fun CoroutineScope.createChatController(
|
||||
cacheScope = cacheScope,
|
||||
currentDefaultAgentId = currentDefaultAgentId,
|
||||
currentDefaultAgentRevision = currentDefaultAgentRevision,
|
||||
gatewayAdvertisesProgressCard = gatewayAdvertisesProgressCard,
|
||||
recordModelRecent = recordModelRecent,
|
||||
onSessionDeleted = onSessionDeleted,
|
||||
onOfflineDefaultAgentRestored = onOfflineDefaultAgentRestored,
|
||||
|
||||
Reference in New Issue
Block a user