diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index d2a3a06b4039..2739794c9c98 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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) { + private fun replaceGatewayMethods(methods: Set?) { 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( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index 32dcf98a11b1..3b46fa2324e8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -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) { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt index 8b6206814d83..66264ba16fee 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerProgressCardTest.kt @@ -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 { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt index ebba33c32d42..5d48af2dee8e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatReplayHarness.kt @@ -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,