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:
Peter Steinberger
2026-08-17 18:16:25 -07:00
parent f7dac033dc
commit 3377a21c4e
4 changed files with 167 additions and 7 deletions
@@ -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) {
@@ -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,