feat(android): hide thinking control and gate sends for models without reasoning support (#101002)

This commit is contained in:
Peter Steinberger 2026-07-07 03:23:08 +01:00 committed by GitHub
parent f309c6054b
commit b2cecd8ece
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 283 additions and 85 deletions

View file

@ -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",

View file

@ -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.

View file

@ -136,6 +136,7 @@ internal fun ChatComposer(
draftText: ChatDraft?,
healthOk: Boolean,
thinkingLevel: String,
thinkingSupported: Boolean,
pendingRunCount: Int,
commands: List<ChatCommandEntry>,
attachments: List<PendingAttachment>,
@ -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 }
}
}
}

View file

@ -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<GatewayModelSummary>,
): Boolean {
val selected = selectedModelRef ?: return true
return catalog.firstOrNull { it.providerQualifiedRef() == selected }?.supportsReasoning != false
}
internal fun chatModelPickerSections(
catalog: List<GatewayModelSummary>,
favorites: List<String>,

View file

@ -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<PendingAttachment>,
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 =

View file

@ -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,

View file

@ -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<String>()
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)
}
}

View file

@ -148,6 +148,7 @@ class ChatControllerOutboxTest {
val sentSessionKeys = mutableListOf<String>()
val sentThinkingLevels = mutableListOf<String>()
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 {

View file

@ -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))
}
}

View file

@ -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,
)
}