From b2cecd8ece47c5720802141a44052cda8f356464 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 7 Jul 2026 03:23:08 +0100 Subject: [PATCH] feat(android): hide thinking control and gate sends for models without reasoning support (#101002) --- apps/.i18n/native-source.json | 82 +++++++++---------- .../ai/openclaw/app/chat/ChatController.kt | 30 +++++-- .../ai/openclaw/app/ui/chat/ChatComposer.kt | 77 +++++++++-------- .../openclaw/app/ui/chat/ChatModelPicker.kt | 8 ++ .../ai/openclaw/app/ui/chat/ChatScreen.kt | 15 +++- .../openclaw/app/ui/chat/ChatSheetContent.kt | 6 ++ .../chat/ChatControllerModelSelectionTest.kt | 63 ++++++++++++++ .../app/chat/ChatControllerOutboxTest.kt | 61 ++++++++++++++ .../app/ui/chat/ChatContextMeterTest.kt | 7 ++ .../app/ui/chat/ChatModelPickerTest.kt | 19 ++++- 10 files changed, 283 insertions(+), 85 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 420871a69d1..20035b31478 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -5787,7 +5787,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 223, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Type a message…", "surface": "android", @@ -5795,7 +5795,7 @@ }, { "kind": "ui-named-argument", - "line": 231, + "line": 236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Gateway is offline. Text messages are queued and sent after reconnecting.", "surface": "android", @@ -5803,7 +5803,7 @@ }, { "kind": "ui-named-argument", - "line": 260, + "line": 266, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Select thinking level", "surface": "android", @@ -5811,7 +5811,7 @@ }, { "kind": "ui-named-argument", - "line": 284, + "line": 291, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Attach", "surface": "android", @@ -5819,7 +5819,7 @@ }, { "kind": "ui-named-argument", - "line": 299, + "line": 306, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Refresh", "surface": "android", @@ -5827,7 +5827,7 @@ }, { "kind": "ui-named-argument", - "line": 307, + "line": 314, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Abort", "surface": "android", @@ -5835,7 +5835,7 @@ }, { "kind": "ui-named-argument", - "line": 356, + "line": 363, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "Send", "surface": "android", @@ -5843,7 +5843,7 @@ }, { "kind": "ui-named-argument", - "line": 382, + "line": 389, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt", "source": "No commands found", "surface": "android", @@ -6131,7 +6131,7 @@ }, { "kind": "ui-named-argument", - "line": 290, + "line": 291, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat needs attention", "surface": "android", @@ -6139,7 +6139,7 @@ }, { "kind": "ui-named-argument", - "line": 448, + "line": 450, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "All", "surface": "android", @@ -6147,7 +6147,7 @@ }, { "kind": "ui-named-argument", - "line": 506, + "line": 508, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw", "surface": "android", @@ -6155,7 +6155,7 @@ }, { "kind": "ui-named-argument", - "line": 527, + "line": 529, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "New chat", "surface": "android", @@ -6163,7 +6163,7 @@ }, { "kind": "ui-named-argument", - "line": 532, + "line": 534, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "More new chat options", "surface": "android", @@ -6171,7 +6171,7 @@ }, { "kind": "ui-named-argument", - "line": 547, + "line": 549, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Refresh chat", "surface": "android", @@ -6179,7 +6179,7 @@ }, { "kind": "ui-named-argument", - "line": 550, + "line": 552, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Chat", "surface": "android", @@ -6187,7 +6187,7 @@ }, { "kind": "ui-named-argument", - "line": 698, + "line": 700, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Loading session", "surface": "android", @@ -6195,7 +6195,7 @@ }, { "kind": "ui-named-argument", - "line": 725, + "line": 727, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Jump to latest", "surface": "android", @@ -6203,7 +6203,7 @@ }, { "kind": "conditional-branch", - "line": 751, + "line": 753, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Ready when you are", "surface": "android", @@ -6211,7 +6211,7 @@ }, { "kind": "ui-named-argument", - "line": 782, + "line": 784, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Fix connection", "surface": "android", @@ -6219,7 +6219,7 @@ }, { "kind": "ui-named-argument", - "line": 783, + "line": 785, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Copy diagnostics", "surface": "android", @@ -6227,7 +6227,7 @@ }, { "kind": "conditional-branch", - "line": 967, + "line": 969, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Preparing audio…", "surface": "android", @@ -6235,7 +6235,7 @@ }, { "kind": "conditional-branch", - "line": 967, + "line": 969, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Speaking…", "surface": "android", @@ -6243,7 +6243,7 @@ }, { "kind": "ui-named-argument", - "line": 988, + "line": 990, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Tools running", "surface": "android", @@ -6251,7 +6251,7 @@ }, { "kind": "ui-named-argument", - "line": 990, + "line": 992, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is working", "surface": "android", @@ -6259,7 +6259,7 @@ }, { "kind": "ui-named-argument", - "line": 993, + "line": 995, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "+${toolCalls.size - 4} more", "surface": "android", @@ -6267,7 +6267,7 @@ }, { "kind": "ui-named-argument", - "line": 1003, + "line": 1005, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Thinking", "surface": "android", @@ -6275,7 +6275,7 @@ }, { "kind": "ui-named-argument", - "line": 1004, + "line": 1006, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "OpenClaw is preparing a response.", "surface": "android", @@ -6283,7 +6283,7 @@ }, { "kind": "ui-named-argument", - "line": 1161, + "line": 1165, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Stop", "surface": "android", @@ -6291,7 +6291,7 @@ }, { "kind": "ui-named-argument", - "line": 1227, + "line": 1231, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Default", "surface": "android", @@ -6299,7 +6299,7 @@ }, { "kind": "conditional-branch", - "line": 1293, + "line": 1297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Pin model", "surface": "android", @@ -6307,7 +6307,7 @@ }, { "kind": "conditional-branch", - "line": 1293, + "line": 1297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Unpin model", "surface": "android", @@ -6315,7 +6315,7 @@ }, { "kind": "ui-named-argument", - "line": 1310, + "line": 1314, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "No commands found", "surface": "android", @@ -6323,7 +6323,7 @@ }, { "kind": "ui-named-argument", - "line": 1372, + "line": 1376, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Gateway offline", "surface": "android", @@ -6331,7 +6331,7 @@ }, { "kind": "ui-named-argument", - "line": 1464, + "line": 1472, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Attach image", "surface": "android", @@ -6339,7 +6339,7 @@ }, { "kind": "ui-named-argument", - "line": 1483, + "line": 1491, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Message OpenClaw", "surface": "android", @@ -6347,7 +6347,7 @@ }, { "kind": "ui-named-argument", - "line": 1498, + "line": 1506, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Open voice", "surface": "android", @@ -6355,7 +6355,7 @@ }, { "kind": "ui-named-argument", - "line": 1547, + "line": 1555, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Remove attachment", "surface": "android", @@ -6363,7 +6363,7 @@ }, { "kind": "conditional-branch", - "line": 1568, + "line": 1576, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Main", "surface": "android", @@ -6371,7 +6371,7 @@ }, { "kind": "ui-named-argument", - "line": 1624, + "line": 1632, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "Send", "surface": "android", @@ -6379,7 +6379,7 @@ }, { "kind": "conditional-branch", - "line": 1663, + "line": 1672, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt", "source": "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}", "surface": "android", @@ -6387,7 +6387,7 @@ }, { "kind": "ui-named-argument", - "line": 291, + "line": 297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt", "source": "CHAT ERROR", "surface": "android", 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 a5b7770cb3b..5b190f12184 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 @@ -7,6 +7,7 @@ import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.parseChatSendAck import ai.openclaw.app.parseGatewayModels import ai.openclaw.app.resolveAgentIdFromMainSessionKey +import ai.openclaw.app.ui.chat.thinkingSupportedForSelection import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -763,6 +764,15 @@ class ChatController internal constructor( val pendingSelection = pendingModelSelections[sessionKey] if (pendingSelection != null && !pendingSelection.await()) return false if (_sessionKey.value != sessionKey) return false + // agent-command.ts throws for explicit unsupported levels, so hidden controls must send off. + // Applied at enqueue time too so durable rows never persist a level the selected model + // rejects; reconnect flushes with a cleared catalog fail open, matching pre-gating behavior. + val thinking = + if (thinkingSupportedForSelection(_selectedModelRef.value, _modelCatalog.value)) { + normalizeThinking(thinkingLevel) + } else { + "off" + } if (!_healthOk.value) { // Offline capture: text-only commands become durable outbox rows and flush on reconnect. // Attachments stay blocked (text-only v1) so large payloads never sit in the database. @@ -770,12 +780,11 @@ class ChatController internal constructor( updateErrorText("Gateway health not OK; cannot send") return false } - return enqueueOfflineCommand(text = trimmed, thinkingLevel = normalizeThinking(thinkingLevel)) + return enqueueOfflineCommand(text = trimmed, thinkingLevel = thinking) } val runId = UUID.randomUUID().toString() val text = if (trimmed.isEmpty() && attachments.isNotEmpty()) "See attached." else trimmed - val thinking = normalizeThinking(thinkingLevel) // Optimistic user message keeps the composer responsive while chat.send and history refresh complete. val userContent = @@ -1585,14 +1594,25 @@ class ChatController internal constructor( gatewayId: String, ): OutboxSendResult = try { + val queuedSessionKey = normalizeRequestedSessionKey(item.sessionKey) + // Android only knows the active session's selected model. Unknown queued sessions fail + // open, preserving the thinking level captured when they were enqueued. + val thinking = + if ( + queuedSessionKey == _sessionKey.value && + !thinkingSupportedForSelection(_selectedModelRef.value, _modelCatalog.value) + ) { + "off" + } else { + item.thinkingLevel + } val params = buildJsonObject { // Rows enqueued under the pre-hello "main" alias must flush to the canonical main // session the gateway announced, matching how the UI attributes those rows. - put("sessionKey", JsonPrimitive(normalizeRequestedSessionKey(item.sessionKey))) + put("sessionKey", JsonPrimitive(queuedSessionKey)) put("message", JsonPrimitive(item.text)) - // Enqueue-time thinking level: a later selector change must not alter queued sends. - put("thinking", JsonPrimitive(item.thinkingLevel)) + put("thinking", JsonPrimitive(thinking)) put("timeoutMs", JsonPrimitive(30_000)) // The row id is the idempotency key, so gateway-side dedupe makes redelivery of an // acked-but-crashed item harmless. diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 09b933afe43..f39e0b6caf1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -136,6 +136,7 @@ internal fun ChatComposer( draftText: ChatDraft?, healthOk: Boolean, thinkingLevel: String, + thinkingSupported: Boolean, pendingRunCount: Int, commands: List, attachments: List, @@ -174,6 +175,10 @@ internal fun ChatComposer( } } + LaunchedEffect(thinkingSupported) { + if (!thinkingSupported) showThinkingMenu = false + } + // One in-flight run owns the composer actions; attachments alone are enough to send when the // gateway is healthy. Offline, only text can be sent (it is queued durably; text-only v1). val canSend = @@ -239,44 +244,46 @@ internal fun ChatComposer( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Box { - Surface( - onClick = { showThinkingMenu = true }, - shape = RoundedCornerShape(14.dp), - color = mobileCardSurface, - border = BorderStroke(1.dp, mobileBorderStrong), - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, + if (thinkingSupported) { + Box { + Surface( + onClick = { showThinkingMenu = true }, + shape = RoundedCornerShape(14.dp), + color = mobileCardSurface, + border = BorderStroke(1.dp, mobileBorderStrong), ) { - Text( - text = thinkingLabel(thinkingLevel), - style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), - color = mobileTextSecondary, - ) - Icon( - Icons.Default.ArrowDropDown, - contentDescription = "Select thinking level", - modifier = Modifier.size(18.dp), - tint = mobileTextTertiary, - ) + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = thinkingLabel(thinkingLevel), + style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), + color = mobileTextSecondary, + ) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = "Select thinking level", + modifier = Modifier.size(18.dp), + tint = mobileTextTertiary, + ) + } } - } - DropdownMenu( - expanded = showThinkingMenu, - onDismissRequest = { showThinkingMenu = false }, - shape = RoundedCornerShape(16.dp), - containerColor = mobileCardSurface, - tonalElevation = 0.dp, - shadowElevation = 8.dp, - border = BorderStroke(1.dp, mobileBorder), - ) { - ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } - ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + DropdownMenu( + expanded = showThinkingMenu, + onDismissRequest = { showThinkingMenu = false }, + shape = RoundedCornerShape(16.dp), + containerColor = mobileCardSurface, + tonalElevation = 0.dp, + shadowElevation = 8.dp, + border = BorderStroke(1.dp, mobileBorder), + ) { + ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatModelPicker.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatModelPicker.kt index 1a0918f620f..eda028169a1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatModelPicker.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatModelPicker.kt @@ -15,6 +15,14 @@ internal fun GatewayModelSummary.providerQualifiedRef(): String { return if (id.startsWith(providerPrefix)) id else "$providerPrefix$id" } +internal fun thinkingSupportedForSelection( + selectedModelRef: String?, + catalog: List, +): Boolean { + val selected = selectedModelRef ?: return true + return catalog.firstOrNull { it.providerQualifiedRef() == selected }?.supportsReasoning != false +} + internal fun chatModelPickerSections( catalog: List, favorites: List, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt index f0a536c5c3b..febee012ca8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt @@ -145,6 +145,7 @@ fun ChatScreen( val micCooldown by viewModel.micCooldown.collectAsState() val talkModeEnabled by viewModel.talkModeEnabled.collectAsState() val talkModeListening by viewModel.talkModeListening.collectAsState() + val thinkingSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog) val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions) val gatewayAddress = gatewayDiagnosticsEndpoint(remoteAddress = remoteAddress, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls) val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() } @@ -321,6 +322,7 @@ fun ChatScreen( onValueChange = { input = it }, attachments = attachments, thinkingLevel = thinkingLevel, + thinkingSupported = thinkingSupported, contextUsage = contextUsage, selectedModelLabel = selectedModelLabel, modelPickerEnabled = gatewayConnectionDisplay.isConnected, @@ -1038,6 +1040,7 @@ private fun ChatComposer( onValueChange: (String) -> Unit, attachments: List, thinkingLevel: String, + thinkingSupported: Boolean, contextUsage: ChatContextUsage, selectedModelLabel: String, modelPickerEnabled: Boolean, @@ -1085,6 +1088,7 @@ private fun ChatComposer( ) ChatContextMeter( thinkingLevel = thinkingLevel, + thinkingSupported = thinkingSupported, contextUsage = contextUsage, onClick = { onThinkingLevelChange(nextThinkingValue(thinkingLevel)) }, ) @@ -1388,6 +1392,7 @@ private fun ChatOfflineNotice( @Composable private fun ChatContextMeter( thinkingLevel: String, + thinkingSupported: Boolean, contextUsage: ChatContextUsage, onClick: () -> Unit, ) { @@ -1399,6 +1404,7 @@ private fun ChatContextMeter( ) { Surface( onClick = onClick, + enabled = thinkingSupported, modifier = Modifier.heightIn(min = ClawTheme.spacing.touchTarget), shape = RoundedCornerShape(ClawTheme.radii.pill), color = ClawTheme.colors.canvas, @@ -1409,9 +1415,11 @@ private fun ChatContextMeter( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle) + if (thinkingSupported) { + Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null, modifier = Modifier.size(13.dp), tint = ClawTheme.colors.textSubtle) + } Text( - text = contextMeterLabel(contextUsage, thinkingLevel), + text = contextMeterLabel(contextUsage, thinkingLevel, thinkingSupported), style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, @@ -1658,9 +1666,10 @@ internal fun contextMeterWidth(usage: ChatContextUsage): Float? { internal fun contextMeterLabel( usage: ChatContextUsage, thinkingLevel: String, + thinkingSupported: Boolean = true, ): String { val contextLabel = contextMeterWidth(usage)?.let { "Context ${(it * 100).roundToInt()}%" } ?: "Context --" - return "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}" + return if (thinkingSupported) "$contextLabel · ${contextMeterThinkingLabel(thinkingLevel)}" else contextLabel } internal fun contextMeterThinkingLabel(value: String): String = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 96f7ffa761d..c6386ee5459 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -79,6 +79,11 @@ fun ChatSheetContent(viewModel: MainViewModel) { val sessionKey by viewModel.chatSessionKey.collectAsState() val mainSessionKey by viewModel.mainSessionKey.collectAsState() val thinkingLevel by viewModel.chatThinkingLevel.collectAsState() + val selectedModelRef by viewModel.chatSelectedModelRef.collectAsState() + // Gate from the controller's agent-scoped catalog — the same source the send gate reads — + // so the visible control can never disagree with what sends actually carry. + val modelCatalog by viewModel.chatModelCatalog.collectAsState() + val thinkingSupported = thinkingSupportedForSelection(selectedModelRef, modelCatalog) val streamingAssistantText by viewModel.chatStreamingAssistantText.collectAsState() val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState() val sessions by viewModel.chatSessions.collectAsState() @@ -204,6 +209,7 @@ fun ChatSheetContent(viewModel: MainViewModel) { draftText = chatDraft, healthOk = healthOk, thinkingLevel = thinkingLevel, + thinkingSupported = thinkingSupported, pendingRunCount = pendingRunCount, commands = chatCommands, attachments = attachments, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt index 50d2907d2b6..87bdb4b7523 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerModelSelectionTest.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -333,4 +335,65 @@ class ChatControllerModelSelectionTest { assertEquals(2, metadataRequests) assertTrue(controller.modelCatalog.value.isEmpty()) } + + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun unsupportedReasoningSendsOffWithoutChangingStoredLevelAndRestoresAfterFlip() = + runTest { + val sentThinkingLevels = mutableListOf() + val controller = + ChatController( + scope = this, + json = json, + requestGateway = { method, paramsJson -> + when (method) { + "chat.send" -> { + val params = json.parseToJsonElement(paramsJson.orEmpty()) as JsonObject + sentThinkingLevels += (params["thinking"] as JsonPrimitive).content + """{"runId":"run-${sentThinkingLevels.size}","status":"ok"}""" + } + "chat.history" -> """{"messages":[],"sessionInfo":{"key":"main"}}""" + "sessions.list" -> """{"sessions":[]}""" + // Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata. + "chat.metadata" -> + """ + { + "commands": [], + "models": [ + {"id": "plain", "name": "plain", "provider": "openai", "available": true, "input": ["text"], "reasoning": false}, + {"id": "reasoning", "name": "reasoning", "provider": "openai", "available": true, "input": ["text"], "reasoning": true} + ] + } + """.trimIndent() + else -> "{}" + } + }, + ) + controller.handleGatewayEvent("health", null) + controller.load("main") + advanceUntilIdle() + controller.setThinkingLevel("high") + assertTrue(controller.setSessionModelAwait("main", "openai/plain")) + + assertTrue( + controller.sendMessageAwaitAcceptance( + message = "plain model", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ), + ) + assertEquals(listOf("off"), sentThinkingLevels) + assertEquals("high", controller.thinkingLevel.value) + + assertTrue(controller.setSessionModelAwait("main", "openai/reasoning")) + assertTrue( + controller.sendMessageAwaitAcceptance( + message = "reasoning restored", + thinkingLevel = controller.thinkingLevel.value, + attachments = emptyList(), + ), + ) + assertEquals(listOf("off", "high"), sentThinkingLevels) + assertEquals("high", controller.thinkingLevel.value) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt index c5ef6a4d5a0..368365578ec 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/chat/ChatControllerOutboxTest.kt @@ -148,6 +148,7 @@ class ChatControllerOutboxTest { val sentSessionKeys = mutableListOf() val sentThinkingLevels = mutableListOf() var historyMessagesJson = "[]" + var metadataModelsJson = "[]" suspend fun request( method: String, @@ -166,6 +167,7 @@ class ChatControllerOutboxTest { sendResponse(key) } "chat.history" -> """{"sessionId":"session-1","messages":$historyMessagesJson}""" + "chat.metadata" -> """{"commands":[],"models":$metadataModelsJson}""" else -> "{}" } } @@ -256,6 +258,65 @@ class ChatControllerOutboxTest { assertTrue(chat.outboxItems.value.isEmpty()) } + @Test + fun reconnectGatesActiveSessionThinkingAndFailsOpenForOtherSessions() = + runTest { + val gateway = FakeGateway() + val outbox = FakeCommandOutbox() + val now = System.currentTimeMillis() + // Gating reads the controller-owned agent-scoped catalog hydrated from chat.metadata, + // so hydrate first (empty queue) and seed the rows afterwards; the flush loop re-reads + // the outbox on each health transition. + gateway.metadataModelsJson = + """[{"id":"plain","name":"Plain","provider":"openai","available":true,"input":["text"],"reasoning":false}]""" + val chat = controller(this, gateway, outbox) + gateway.online = true + chat.load("main") + advanceUntilIdle() + + outbox.seed( + ChatOutboxItem( + id = "active", + sessionKey = "main", + text = "active session", + thinkingLevel = "high", + createdAtMs = now, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ), + ) + outbox.seed( + ChatOutboxItem( + id = "other", + sessionKey = "other-session", + text = "unknown session", + thinkingLevel = "medium", + createdAtMs = now + 1, + status = ChatOutboxStatus.Queued, + retryCount = 0, + lastError = null, + ), + ) + assertTrue(chat.setSessionModelAwait("main", "openai/plain")) + // Drop health via a transport failure mid-flush: unlike a disconnect this keeps the + // hydrated catalog, which is the state where the flush gate has data to act on. + gateway.failSendsWithTransportError = true + chat.retryOutboxCommand("active") + advanceUntilIdle() + assertFalse(chat.healthOk.value) + + gateway.failSendsWithTransportError = false + chat.handleGatewayEvent("health", null) + advanceUntilIdle() + + // retryOutboxCommand refreshes the active row's createdAt, so the untouched + // unknown-session row flushes first in createdAt order. + assertEquals(listOf("unknown session", "active session"), gateway.sentMessages) + assertEquals(listOf("medium", "off"), gateway.sentThinkingLevels) + assertTrue(chat.outboxItems.value.isEmpty()) + } + @Test fun mainAliasRowsFlushToCanonicalMainSessionAfterHello() = runTest { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt index 32bb338b91b..16c078b3222 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatContextMeterTest.kt @@ -81,4 +81,11 @@ class ChatContextMeterTest { assertNull(contextMeterWidth(usage)) assertEquals("Context -- · high", contextMeterLabel(usage, "high")) } + + @Test + fun contextMeterHidesThinkingLabelWhenUnsupported() { + val usage = ChatContextUsage(totalTokens = 2_500L, totalTokensFresh = true, contextTokens = 10_000L) + + assertEquals("Context 25%", contextMeterLabel(usage, "high", thinkingSupported = false)) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt index e25434b7644..ea6c6c1f4dd 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatModelPickerTest.kt @@ -2,6 +2,8 @@ package ai.openclaw.app.ui.chat import ai.openclaw.app.GatewayModelSummary import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ChatModelPickerTest { @@ -33,9 +35,24 @@ class ChatModelPickerTest { assertEquals(listOf("two/b"), sections.remaining.map { it.providerQualifiedRef() }) } + @Test + fun thinkingSupportFailsOpenUnlessMatchedModelDisablesReasoning() { + val catalog = + listOf( + model(id = "reasoning", provider = "openai", supportsReasoning = true), + model(id = "plain", provider = "openai", supportsReasoning = false), + ) + + assertTrue(thinkingSupportedForSelection(selectedModelRef = null, catalog = catalog)) + assertTrue(thinkingSupportedForSelection(selectedModelRef = "openai/unknown", catalog = catalog)) + assertTrue(thinkingSupportedForSelection(selectedModelRef = "openai/reasoning", catalog = catalog)) + assertFalse(thinkingSupportedForSelection(selectedModelRef = "openai/plain", catalog = catalog)) + } + private fun model( id: String, provider: String, + supportsReasoning: Boolean = false, ): GatewayModelSummary = GatewayModelSummary( id = id, @@ -45,7 +62,7 @@ class ChatModelPickerTest { supportsVision = false, supportsAudio = false, supportsDocuments = false, - supportsReasoning = false, + supportsReasoning = supportsReasoning, contextTokens = null, ) }