mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(android): address overhaul review findings
This commit is contained in:
@@ -93,7 +93,6 @@ class MainViewModel(
|
||||
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
|
||||
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
|
||||
val cronSaving: StateFlow<Boolean> = runtimeState(initial = false) { it.cronSaving }
|
||||
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
|
||||
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
|
||||
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
|
||||
@@ -329,6 +328,10 @@ class MainViewModel(
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
ensureRuntime().cancelMicCapture()
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setTalkModeEnabled(enabled)
|
||||
}
|
||||
@@ -403,20 +406,6 @@ class MainViewModel(
|
||||
ensureRuntime().refreshCronJobs()
|
||||
}
|
||||
|
||||
fun createCronJob(
|
||||
name: String,
|
||||
message: String,
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
) {
|
||||
ensureRuntime().createCronJob(
|
||||
name = name,
|
||||
message = message,
|
||||
scheduleKind = scheduleKind,
|
||||
scheduleValue = scheduleValue,
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
ensureRuntime().refreshUsage()
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -323,8 +322,6 @@ class NodeRuntime(
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = _cronJobs.asStateFlow()
|
||||
private val _cronRefreshing = MutableStateFlow(false)
|
||||
val cronRefreshing: StateFlow<Boolean> = _cronRefreshing.asStateFlow()
|
||||
private val _cronSaving = MutableStateFlow(false)
|
||||
val cronSaving: StateFlow<Boolean> = _cronSaving.asStateFlow()
|
||||
private val _cronErrorText = MutableStateFlow<String?>(null)
|
||||
val cronErrorText: StateFlow<String?> = _cronErrorText.asStateFlow()
|
||||
private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList()))
|
||||
@@ -413,6 +410,8 @@ class NodeRuntime(
|
||||
_gatewayVersion.value = null
|
||||
_gatewayUpdateAvailable.value = null
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
_gatewayDefaultAgentId.value = null
|
||||
_gatewayAgents.value = emptyList()
|
||||
_modelCatalog.value = emptyList()
|
||||
_modelAuthProviders.value = emptyList()
|
||||
_cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)
|
||||
@@ -722,22 +721,6 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
fun createCronJob(
|
||||
name: String,
|
||||
message: String,
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
) {
|
||||
scope.launch {
|
||||
createCronJobOnGateway(
|
||||
name = name,
|
||||
message = message,
|
||||
scheduleKind = scheduleKind,
|
||||
scheduleValue = scheduleValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
scope.launch {
|
||||
refreshUsageFromGateway()
|
||||
@@ -1102,6 +1085,12 @@ class NodeRuntime(
|
||||
setVoiceCaptureMode(if (value) VoiceCaptureMode.ManualMic else VoiceCaptureMode.Off)
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
micCapture.cancelMicCapture()
|
||||
setVoiceCaptureMode(VoiceCaptureMode.Off, persistManualMic = false)
|
||||
prefs.setVoiceMicEnabled(false)
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(value: Boolean) {
|
||||
setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off)
|
||||
}
|
||||
@@ -1763,119 +1752,6 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createCronJobOnGateway(
|
||||
name: String,
|
||||
message: String,
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
) {
|
||||
_cronSaving.value = true
|
||||
_cronErrorText.value = null
|
||||
if (!operatorConnected) {
|
||||
_cronErrorText.value = "Connect the gateway before creating a cron job."
|
||||
_cronSaving.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
val cleanName = name.trim()
|
||||
val cleanMessage = message.trim()
|
||||
if (cleanName.isEmpty()) {
|
||||
throw IllegalArgumentException("Add a job name.")
|
||||
}
|
||||
if (cleanMessage.isEmpty()) {
|
||||
throw IllegalArgumentException("Add the message OpenClaw should run.")
|
||||
}
|
||||
val schedule = buildCronCreateSchedule(scheduleKind = scheduleKind, scheduleValue = scheduleValue.trim())
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("name", JsonPrimitive(cleanName))
|
||||
put("enabled", JsonPrimitive(true))
|
||||
put("deleteAfterRun", JsonPrimitive(scheduleKind == "Once"))
|
||||
put("sessionTarget", JsonPrimitive("isolated"))
|
||||
put("wakeMode", JsonPrimitive("now"))
|
||||
put("schedule", schedule)
|
||||
put(
|
||||
"payload",
|
||||
buildJsonObject {
|
||||
put("kind", JsonPrimitive("agentTurn"))
|
||||
put("message", JsonPrimitive(cleanMessage))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"delivery",
|
||||
buildJsonObject {
|
||||
put("mode", JsonPrimitive("announce"))
|
||||
put("channel", JsonPrimitive("last"))
|
||||
put("bestEffort", JsonPrimitive(true))
|
||||
},
|
||||
)
|
||||
}
|
||||
operatorSession.request("cron.add", params.toString())
|
||||
refreshCronFromGateway()
|
||||
} catch (err: IllegalArgumentException) {
|
||||
_cronErrorText.value = err.message ?: "Could not create cron job."
|
||||
} catch (_: Throwable) {
|
||||
_cronErrorText.value = "Could not create cron job."
|
||||
} finally {
|
||||
_cronSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCronCreateSchedule(
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
): JsonObject =
|
||||
when (scheduleKind) {
|
||||
"Every" ->
|
||||
buildJsonObject {
|
||||
put("kind", JsonPrimitive("every"))
|
||||
put("everyMs", JsonPrimitive(parseCronDurationMs(scheduleValue)))
|
||||
}
|
||||
"Once" ->
|
||||
buildJsonObject {
|
||||
put("kind", JsonPrimitive("at"))
|
||||
put("at", JsonPrimitive(resolveCronAtIso(scheduleValue)))
|
||||
}
|
||||
"Cron" ->
|
||||
buildJsonObject {
|
||||
val expr = scheduleValue.takeIf { it.isNotEmpty() } ?: throw IllegalArgumentException("Add a cron expression.")
|
||||
put("kind", JsonPrimitive("cron"))
|
||||
put("expr", JsonPrimitive(expr))
|
||||
put("staggerMs", JsonPrimitive(0))
|
||||
}
|
||||
else -> throw IllegalArgumentException("Choose a schedule.")
|
||||
}
|
||||
|
||||
private fun resolveCronAtIso(value: String): String {
|
||||
val clean = value.trim()
|
||||
if (clean.startsWith("+")) {
|
||||
return Instant.ofEpochMilli(System.currentTimeMillis() + parseCronDurationMs(clean.drop(1))).toString()
|
||||
}
|
||||
return try {
|
||||
Instant.parse(clean).toString()
|
||||
} catch (_: Throwable) {
|
||||
throw IllegalArgumentException("Use ISO time or +30m.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCronDurationMs(value: String): Long {
|
||||
val match =
|
||||
Regex("""^(\d+)\s*([mhd])$""")
|
||||
.matchEntire(value.trim().lowercase())
|
||||
?: throw IllegalArgumentException("Use 15m, 1h, or 1d.")
|
||||
val amount = match.groupValues[1].toLong()
|
||||
if (amount <= 0) {
|
||||
throw IllegalArgumentException("Use a duration greater than zero.")
|
||||
}
|
||||
val unit = match.groupValues[2]
|
||||
return amount *
|
||||
when (unit) {
|
||||
"m" -> 60_000L
|
||||
"h" -> 3_600_000L
|
||||
else -> 86_400_000L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshUsageFromGateway() {
|
||||
_usageRefreshing.value = true
|
||||
_usageErrorText.value = null
|
||||
@@ -2151,12 +2027,7 @@ class NodeRuntime(
|
||||
scheduleLabel = cronScheduleLabel(schedule),
|
||||
promptPreview = cronPayloadPreview(payload),
|
||||
nextRunAtMs = state.long("nextRunAtMs"),
|
||||
lastRunStatus =
|
||||
state
|
||||
?.get("lastRunStatus")
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
lastRunStatus = cronJobLastRunStatus(state),
|
||||
)
|
||||
}.orEmpty()
|
||||
|
||||
@@ -2649,11 +2520,7 @@ internal fun resolveOperatorSessionConnectAuth(
|
||||
|
||||
val explicitBootstrapToken = auth.bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (explicitBootstrapToken != null) {
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = null,
|
||||
bootstrapToken = explicitBootstrapToken,
|
||||
password = null,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
@@ -2867,6 +2734,18 @@ private fun JsonObject?.double(key: String): Double? = (this?.get(key) as? JsonP
|
||||
|
||||
private fun JsonObject?.boolean(key: String): Boolean = (this?.get(key) as? JsonPrimitive)?.content?.trim() == "true"
|
||||
|
||||
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
|
||||
state
|
||||
.cronStatus("lastStatus")
|
||||
?: state.cronStatus("lastRunStatus")
|
||||
|
||||
private fun JsonObject?.cronStatus(key: String): String? =
|
||||
this
|
||||
?.get(key)
|
||||
.asStringOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
fun providerDisplayName(provider: String): String =
|
||||
when (provider.trim().lowercase()) {
|
||||
"openai" -> "OpenAI"
|
||||
|
||||
@@ -233,6 +233,7 @@ class ChatController(
|
||||
true
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = err.message
|
||||
false
|
||||
}
|
||||
@@ -374,7 +375,13 @@ class ChatController(
|
||||
if (state == "error") {
|
||||
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
|
||||
}
|
||||
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
|
||||
if (runId != null) {
|
||||
clearPendingRun(runId)
|
||||
optimisticMessagesByRunId.remove(runId)
|
||||
} else {
|
||||
clearPendingRuns()
|
||||
optimisticMessagesByRunId.clear()
|
||||
}
|
||||
pendingToolCallsById.clear()
|
||||
publishPendingToolCalls()
|
||||
_streamingAssistantText.value = null
|
||||
@@ -476,6 +483,7 @@ class ChatController(
|
||||
}
|
||||
if (!stillPending) return@launch
|
||||
clearPendingRun(runId)
|
||||
removeOptimisticMessage(runId)
|
||||
_errorText.value = "Timed out waiting for a reply; try again or refresh."
|
||||
}
|
||||
}
|
||||
@@ -493,12 +501,18 @@ class ChatController(
|
||||
job.cancel()
|
||||
}
|
||||
pendingRunTimeoutJobs.clear()
|
||||
optimisticMessagesByRunId.clear()
|
||||
synchronized(pendingRuns) {
|
||||
pendingRuns.clear()
|
||||
_pendingRunCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeOptimisticMessage(runId: String) {
|
||||
val message = optimisticMessagesByRunId.remove(runId) ?: return
|
||||
_messages.value = _messages.value.filterNot { it.id == message.id }
|
||||
}
|
||||
|
||||
private fun parseHistory(
|
||||
historyJson: String,
|
||||
sessionKey: String,
|
||||
@@ -631,11 +645,19 @@ internal fun mergeOptimisticMessages(
|
||||
): List<ChatMessage> {
|
||||
if (optimistic.isEmpty()) return incoming
|
||||
|
||||
val incomingKeys = incoming.mapNotNull(::messageIdentityKey).toSet()
|
||||
val unmatchedIncoming = incoming.toMutableList()
|
||||
val missingOptimistic =
|
||||
optimistic.filter { message ->
|
||||
val key = messageIdentityKey(message) ?: return@filter false
|
||||
key !in incomingKeys
|
||||
val matchIndex =
|
||||
unmatchedIncoming.indexOfFirst { incomingMessage ->
|
||||
incomingMessageConsumesOptimistic(incomingMessage, message)
|
||||
}
|
||||
if (matchIndex >= 0) {
|
||||
unmatchedIncoming.removeAt(matchIndex)
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
if (missingOptimistic.isEmpty()) return incoming
|
||||
|
||||
@@ -643,10 +665,28 @@ internal fun mergeOptimisticMessages(
|
||||
}
|
||||
|
||||
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
val contentKey = messageContentIdentityKey(message) ?: return null
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
|
||||
return listOf(contentKey, timestamp).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
|
||||
|
||||
private fun incomingMessageConsumesOptimistic(
|
||||
incoming: ChatMessage,
|
||||
optimistic: ChatMessage,
|
||||
): Boolean {
|
||||
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
|
||||
val incomingTimestamp = incoming.timestampMs ?: return false
|
||||
val optimisticTimestamp = optimistic.timestampMs ?: return true
|
||||
return incomingTimestamp >= optimisticTimestamp
|
||||
}
|
||||
|
||||
private fun messageContentIdentityKey(message: ChatMessage): String? {
|
||||
val role = message.role.trim().lowercase()
|
||||
if (role.isEmpty()) return null
|
||||
|
||||
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||
val contentFingerprint =
|
||||
message.content.joinToString(separator = "\u001E") { part ->
|
||||
listOf(
|
||||
@@ -664,8 +704,7 @@ internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||
).joinToString(separator = "\u001F")
|
||||
}
|
||||
|
||||
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||
return listOf(role, contentFingerprint).joinToString(separator = "|")
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
@@ -618,6 +618,7 @@ class GatewaySession(
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
)
|
||||
|
||||
@@ -167,6 +167,7 @@ class ConnectionManager(
|
||||
scopes =
|
||||
listOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
),
|
||||
|
||||
@@ -42,6 +42,7 @@ internal fun CanvasSettingsScreen(
|
||||
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
|
||||
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
|
||||
val hasLivePage = currentUrl?.isNotBlank() == true
|
||||
val showCanvasSurface = isConnected
|
||||
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
@@ -92,7 +93,7 @@ internal fun CanvasSettingsScreen(
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Box {
|
||||
if (hasLivePage) {
|
||||
if (showCanvasSurface) {
|
||||
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
|
||||
} else {
|
||||
CanvasStandbyPanel(isConnected = isConnected)
|
||||
|
||||
@@ -75,6 +75,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -93,6 +94,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
|
||||
@@ -840,21 +844,22 @@ private fun resolveGatewayConfig(
|
||||
val setup = setupCode.takeIf { it.isNotBlank() }?.let(::decodeGatewaySetupCode)
|
||||
if (setup != null) {
|
||||
val endpoint = parseGatewayEndpointResult(setup.url).config ?: return null
|
||||
val bootstrapToken = setup.bootstrapToken?.trim().orEmpty()
|
||||
return GatewayConfig(
|
||||
host = endpoint.host,
|
||||
port = endpoint.port,
|
||||
tls = endpoint.tls,
|
||||
bootstrapToken = setup.bootstrapToken.orEmpty(),
|
||||
bootstrapToken = bootstrapToken,
|
||||
token =
|
||||
setup.token
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { token.trim() },
|
||||
.ifEmpty { if (bootstrapToken.isEmpty()) token.trim() else "" },
|
||||
password =
|
||||
setup.password
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { password.trim() },
|
||||
.ifEmpty { if (bootstrapToken.isEmpty()) password.trim() else "" },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -962,6 +967,18 @@ private fun rememberPermissionState(
|
||||
)
|
||||
}
|
||||
var callLogGranted by rememberSaveable { mutableStateOf(!callLogAvailable || hasPermission(context, Manifest.permission.READ_CALL_LOG)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(lifecycleOwner, context) {
|
||||
val observer =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
notificationListenerGranted = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val permissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||
@@ -1015,7 +1032,6 @@ private fun rememberPermissionState(
|
||||
},
|
||||
PermissionRowModel("Notification listener", "Forward selected app alerts", Icons.Default.Sensors, notificationListenerGranted) {
|
||||
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
notificationListenerGranted = DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
},
|
||||
if (motionAvailable) {
|
||||
PermissionRowModel("Motion", "Read activity and step context", Icons.Default.Sensors, motionGranted) {
|
||||
|
||||
@@ -247,7 +247,11 @@ private fun providerSetupSubtitle(
|
||||
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
return normalized == "ok" || normalized == "ready" || normalized == "healthy" || normalized == "configured"
|
||||
return normalized == "ok" ||
|
||||
normalized == "ready" ||
|
||||
normalized == "healthy" ||
|
||||
normalized == "configured" ||
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
|
||||
@@ -8,6 +8,7 @@ import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDetailRow
|
||||
import ai.openclaw.app.ui.design.ClawIconBadge
|
||||
@@ -200,14 +201,8 @@ private fun CronJobsSettingsScreen(
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val cronJobs by viewModel.cronJobs.collectAsState()
|
||||
val cronRefreshing by viewModel.cronRefreshing.collectAsState()
|
||||
val cronSaving by viewModel.cronSaving.collectAsState()
|
||||
val cronErrorText by viewModel.cronErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
var jobName by remember { mutableStateOf("") }
|
||||
var jobMessage by remember { mutableStateOf("") }
|
||||
var scheduleKind by remember { mutableStateOf("Every") }
|
||||
var scheduleValue by remember { mutableStateOf("1h") }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -224,38 +219,9 @@ private fun CronJobsSettingsScreen(
|
||||
SettingsMetric("Next Wake", formatCronWake(cronStatus.nextWakeAtMs)),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = if (showEditor) "Close" else "New Job", onClick = { showEditor = !showEditor }, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (showEditor) {
|
||||
CronJobEditorPanel(
|
||||
name = jobName,
|
||||
onNameChange = { jobName = it },
|
||||
message = jobMessage,
|
||||
onMessageChange = { jobMessage = it },
|
||||
scheduleKind = scheduleKind,
|
||||
onScheduleKindChange = {
|
||||
scheduleKind = it
|
||||
scheduleValue =
|
||||
when (it) {
|
||||
"Once" -> "+30m"
|
||||
"Cron" -> "0 9 * * *"
|
||||
else -> "1h"
|
||||
}
|
||||
},
|
||||
scheduleValue = scheduleValue,
|
||||
onScheduleValueChange = { scheduleValue = it },
|
||||
saving = cronSaving,
|
||||
onSave = {
|
||||
viewModel.createCronJob(
|
||||
name = jobName,
|
||||
message = jobMessage,
|
||||
scheduleKind = scheduleKind,
|
||||
scheduleValue = scheduleValue,
|
||||
)
|
||||
},
|
||||
)
|
||||
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.fillMaxWidth())
|
||||
ClawPanel {
|
||||
Text(text = "Android shows scheduled work status. Create and edit schedules from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
cronErrorText?.let { errorText ->
|
||||
ClawPanel {
|
||||
@@ -271,7 +237,7 @@ private fun CronJobsSettingsScreen(
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No scheduled jobs.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Create recurring OpenClaw work here or from the WebUI.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = "Create recurring OpenClaw work from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> CronJobsPanel(jobs = cronJobs)
|
||||
@@ -279,44 +245,6 @@ private fun CronJobsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CronJobEditorPanel(
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
message: String,
|
||||
onMessageChange: (String) -> Unit,
|
||||
scheduleKind: String,
|
||||
onScheduleKindChange: (String) -> Unit,
|
||||
scheduleValue: String,
|
||||
onScheduleValueChange: (String) -> Unit,
|
||||
saving: Boolean,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
val canSave = !saving && name.isNotBlank() && message.isNotBlank() && scheduleValue.isNotBlank()
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "New Job", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
ClawTextField(value = name, onValueChange = onNameChange, placeholder = "Name")
|
||||
ClawTextField(value = message, onValueChange = onMessageChange, placeholder = "Message OpenClaw should run", minLines = 3)
|
||||
ClawSegmentedControl(
|
||||
options = listOf("Every", "Once", "Cron"),
|
||||
selected = scheduleKind,
|
||||
onSelect = onScheduleKindChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ClawTextField(value = scheduleValue, onValueChange = onScheduleValueChange, placeholder = cronSchedulePlaceholder(scheduleKind))
|
||||
ClawPrimaryButton(text = if (saving) "Saving" else "Save Job", onClick = onSave, enabled = canSave, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cronSchedulePlaceholder(scheduleKind: String): String =
|
||||
when (scheduleKind) {
|
||||
"Once" -> "+30m or 2026-05-19T18:00:00Z"
|
||||
"Cron" -> "0 9 * * *"
|
||||
else -> "15m, 1h, or 1d"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgentsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
@@ -726,6 +654,19 @@ private fun GatewaySettingsScreen(
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val savedBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState()
|
||||
val savedGatewayToken by viewModel.gatewayToken.collectAsState()
|
||||
var setupCode by remember { mutableStateOf("") }
|
||||
var hostInput by remember(manualHost) { mutableStateOf(manualHost.ifBlank { "127.0.0.1" }) }
|
||||
var portInput by remember(manualPort) { mutableStateOf(manualPort.toString()) }
|
||||
var tlsInput by remember(manualTls) { mutableStateOf(manualTls) }
|
||||
var tokenInput by remember { mutableStateOf("") }
|
||||
var bootstrapTokenInput by remember { mutableStateOf("") }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var validationText by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
@@ -742,6 +683,78 @@ private fun GatewaySettingsScreen(
|
||||
ClawPrimaryButton(text = "Reconnect", onClick = viewModel::refreshGatewayConnection, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = "Disconnect", onClick = viewModel::disconnect, modifier = Modifier.weight(1f))
|
||||
}
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = "Connection Setup", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
ClawTextField(value = setupCode, onValueChange = { setupCode = it }, placeholder = "Setup code")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawTextField(value = hostInput, onValueChange = { hostInput = it }, placeholder = "Host", modifier = Modifier.weight(1f))
|
||||
ClawTextField(value = portInput, onValueChange = { portInput = it }, placeholder = "Port", modifier = Modifier.weight(0.55f))
|
||||
}
|
||||
ClawSegmentedControl(
|
||||
options = listOf("Local", "TLS"),
|
||||
selected = if (tlsInput) "TLS" else "Local",
|
||||
onSelect = { selected -> tlsInput = selected == "TLS" },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawTextField(value = tokenInput, onValueChange = { tokenInput = it }, placeholder = "Token", modifier = Modifier.weight(1f))
|
||||
ClawTextField(value = bootstrapTokenInput, onValueChange = { bootstrapTokenInput = it }, placeholder = "Bootstrap", modifier = Modifier.weight(1f))
|
||||
}
|
||||
ClawTextField(value = passwordInput, onValueChange = { passwordInput = it }, placeholder = "Password")
|
||||
validationText?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
|
||||
}
|
||||
ClawPrimaryButton(
|
||||
text = "Save & Connect",
|
||||
onClick = {
|
||||
val setup = setupCode.trim().takeIf { it.isNotEmpty() }?.let(::decodeGatewaySetupCode)
|
||||
val endpointConfig =
|
||||
if (setup != null) {
|
||||
parseGatewayEndpointResult(setup.url).config
|
||||
} else {
|
||||
composeGatewayManualUrl(hostInput, portInput, tlsInput)?.let { parseGatewayEndpointResult(it).config }
|
||||
}
|
||||
if (endpointConfig == null) {
|
||||
validationText = "Enter a valid setup code or gateway address."
|
||||
return@ClawPrimaryButton
|
||||
}
|
||||
val bootstrapToken =
|
||||
setup
|
||||
?.bootstrapToken
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { bootstrapTokenInput.trim().ifEmpty { savedBootstrapToken } }
|
||||
val token =
|
||||
setup
|
||||
?.token
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { tokenInput.trim().ifEmpty { if (bootstrapToken.isBlank()) savedGatewayToken else "" } }
|
||||
val password =
|
||||
setup
|
||||
?.password
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifEmpty { passwordInput.trim() }
|
||||
validationText = null
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(endpointConfig.host)
|
||||
viewModel.setManualPort(endpointConfig.port)
|
||||
viewModel.setManualTls(endpointConfig.tls)
|
||||
viewModel.setGatewayBootstrapToken(bootstrapToken)
|
||||
viewModel.setGatewayToken(token)
|
||||
viewModel.setGatewayPassword(password)
|
||||
viewModel.connect(
|
||||
GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port),
|
||||
token = token.ifEmpty { null },
|
||||
bootstrapToken = bootstrapToken.ifEmpty { null },
|
||||
password = password.ifEmpty { null },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewaySkillSummary
|
||||
import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NodeRuntime
|
||||
import ai.openclaw.app.ui.chat.ChatScreen
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
@@ -50,10 +51,12 @@ import androidx.compose.material.icons.outlined.ChatBubbleOutline
|
||||
import androidx.compose.material.icons.outlined.Inventory2
|
||||
import androidx.compose.material.icons.outlined.MicNone
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -94,6 +97,7 @@ fun ShellScreen(
|
||||
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
|
||||
var commandOpen by rememberSaveable { mutableStateOf(false) }
|
||||
val requestedHomeDestination by viewModel.requestedHomeDestination.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
|
||||
LaunchedEffect(requestedHomeDestination) {
|
||||
val destination = requestedHomeDestination ?: return@LaunchedEffect
|
||||
@@ -221,10 +225,49 @@ fun ShellScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { prompt ->
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayTrustDialog(
|
||||
prompt: NodeRuntime.GatewayTrustPrompt,
|
||||
onAccept: () -> Unit,
|
||||
onDecline: () -> Unit,
|
||||
) {
|
||||
val message =
|
||||
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
|
||||
"Verify the certificate fingerprint before trusting this gateway.\n\n${prompt.fingerprintSha256}"
|
||||
} else {
|
||||
"The gateway certificate changed. Continue only if you expected this.\n\nOld SHA-256:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256:\n${prompt.fingerprintSha256}"
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDecline,
|
||||
containerColor = ClawTheme.colors.surfaceRaised,
|
||||
title = { Text("Trust this gateway?", style = ClawTheme.type.section, color = ClawTheme.colors.text) },
|
||||
text = { Text(message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onAccept) {
|
||||
Text("Trust")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDecline) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OverviewScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
||||
@@ -145,7 +145,7 @@ fun VoiceScreen(
|
||||
sending = micIsSending,
|
||||
statusText = activeStatus,
|
||||
gatewayStatus = gatewayStatus,
|
||||
onCancel = { viewModel.setMicEnabled(false) },
|
||||
onCancel = { viewModel.cancelMicCapture() },
|
||||
onSend = { viewModel.setMicEnabled(false) },
|
||||
onOpenVoiceSettings = onOpenVoiceSettings,
|
||||
)
|
||||
|
||||
@@ -114,7 +114,10 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
|
||||
if (loadSessionKey != null) {
|
||||
viewModel.loadChat(loadSessionKey)
|
||||
}
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,16 @@ internal suspend fun dispatchPendingAssistantAutoSend(
|
||||
return dispatch(prompt)
|
||||
}
|
||||
|
||||
internal fun resolveInitialChatLoadSessionKey(
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
): String? {
|
||||
val current = sessionKey.trim()
|
||||
val main = mainSessionKey.trim().ifEmpty { "main" }
|
||||
if (current.isNotEmpty() && current != "main" && current != main) return null
|
||||
return main
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
@@ -87,7 +97,10 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadChat(mainSessionKey)
|
||||
val loadSessionKey = resolveInitialChatLoadSessionKey(sessionKey, mainSessionKey)
|
||||
if (loadSessionKey != null) {
|
||||
viewModel.loadChat(loadSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pendingAssistantAutoSend, healthOk, pendingRunCount, thinkingLevel) {
|
||||
|
||||
@@ -186,6 +186,15 @@ class MicCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelMicCapture() {
|
||||
transcriptionDrainJob?.cancel()
|
||||
transcriptionDrainJob = null
|
||||
_micEnabled.value = false
|
||||
_micCooldown.value = false
|
||||
_liveTranscript.value = null
|
||||
stop()
|
||||
}
|
||||
|
||||
suspend fun pauseForTts() {
|
||||
val shouldPause =
|
||||
synchronized(ttsPauseLock) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CronJobStatusParsingTest {
|
||||
@Test
|
||||
fun cronJobLastRunStatusReadsGatewayLastStatus() {
|
||||
val state =
|
||||
buildJsonObject {
|
||||
put("lastStatus", JsonPrimitive(" error "))
|
||||
put("lastRunStatus", JsonPrimitive("success"))
|
||||
}
|
||||
|
||||
assertEquals("error", cronJobLastRunStatus(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cronJobLastRunStatusReadsLastRunStatus() {
|
||||
val state =
|
||||
buildJsonObject {
|
||||
put("lastRunStatus", JsonPrimitive("error"))
|
||||
}
|
||||
|
||||
assertEquals("error", cronJobLastRunStatus(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cronJobLastRunStatusIgnoresEmptyStatus() {
|
||||
val state =
|
||||
buildJsonObject {
|
||||
put("lastStatus", JsonPrimitive(" "))
|
||||
}
|
||||
|
||||
assertNull(cronJobLastRunStatus(state))
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,14 @@ import java.util.UUID
|
||||
@Config(sdk = [34])
|
||||
class GatewayBootstrapAuthTest {
|
||||
@Test
|
||||
fun connectsOperatorSessionWhenOnlyBootstrapAuthExists() {
|
||||
assertTrue(
|
||||
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
|
||||
storedOperatorToken = "",
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
@@ -85,14 +85,14 @@ class GatewayBootstrapAuthTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveOperatorSessionConnectAuthUsesBootstrapWhenNoStoredOperatorTokenExists() {
|
||||
fun resolveOperatorSessionConnectAuthIgnoresBootstrapWhenNoStoredOperatorTokenExists() {
|
||||
val resolved =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
)
|
||||
|
||||
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null), resolved)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -183,7 +183,7 @@ class GatewayBootstrapAuthTest {
|
||||
|
||||
assertEquals("f1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "nodeSession"))
|
||||
assertEquals("setup-bootstrap-token", waitForDesiredBootstrapToken(runtime, "operatorSession"))
|
||||
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -116,4 +116,53 @@ class ChatControllerMessageIdentityTest {
|
||||
|
||||
assertEquals(listOf("remote-user"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotDuplicateGatewayPersistedUserTurnWithDifferentTimestamp() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val remoteUser = optimistic.copy(id = "remote-user", timestampMs = 2000L)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("remote-user"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesKeepsRepeatedOptimisticTurnWhenHistoryOnlyHasOneMatch() {
|
||||
val first =
|
||||
ChatMessage(
|
||||
id = "local-user-1",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||
timestampMs = 1000L,
|
||||
)
|
||||
val second = first.copy(id = "local-user-2", timestampMs = 1100L)
|
||||
val remoteUser = first.copy(id = "remote-user", timestampMs = 2000L)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(remoteUser), optimistic = listOf(first, second))
|
||||
|
||||
assertEquals(listOf("local-user-2", "remote-user"), merged.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergeOptimisticMessagesDoesNotConsumeOlderIdenticalHistoryTurn() {
|
||||
val optimistic =
|
||||
ChatMessage(
|
||||
id = "local-user",
|
||||
role = "user",
|
||||
content = listOf(ChatMessageContent(type = "text", text = "ok")),
|
||||
timestampMs = 2000L,
|
||||
)
|
||||
val oldHistoryUser = optimistic.copy(id = "remote-old-user", timestampMs = 1000L)
|
||||
|
||||
val merged = mergeOptimisticMessages(incoming = listOf(oldHistoryUser), optimistic = listOf(optimistic))
|
||||
|
||||
assertEquals(listOf("remote-old-user", "local-user"), merged.map { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ class GatewaySessionInvokeTest {
|
||||
connectResponseFrame(
|
||||
id,
|
||||
authJson =
|
||||
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""",
|
||||
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.pairing","operator.read","operator.talk.secrets","operator.write"]}]}""",
|
||||
),
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
@@ -365,7 +365,7 @@ class GatewaySessionInvokeTest {
|
||||
assertEquals(emptyList<String>(), nodeEntry?.scopes)
|
||||
assertEquals("bootstrap-operator-token", operatorEntry?.token)
|
||||
assertEquals(
|
||||
listOf("operator.approvals", "operator.read", "operator.write"),
|
||||
listOf("operator.approvals", "operator.pairing", "operator.read", "operator.write"),
|
||||
operatorEntry?.scopes,
|
||||
)
|
||||
} finally {
|
||||
|
||||
@@ -375,6 +375,7 @@ class ConnectionManagerTest {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProviderModelStatusTest {
|
||||
@Test
|
||||
fun staticProviderStatusIsReady() {
|
||||
assertTrue(modelProviderReady("static"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingProviderStatusIsNotReady() {
|
||||
assertFalse(modelProviderReady("missing"))
|
||||
}
|
||||
}
|
||||
@@ -71,4 +71,25 @@ class ChatSheetContentTest {
|
||||
assertTrue(consumed)
|
||||
assertEquals("summarize mail", dispatchedPrompt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialChatLoadUsesMainWhenNoSessionIsSelected() {
|
||||
assertEquals(
|
||||
"agent:ops:device",
|
||||
resolveInitialChatLoadSessionKey(
|
||||
sessionKey = "main",
|
||||
mainSessionKey = "agent:ops:device",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialChatLoadPreservesSelectedSession() {
|
||||
assertNull(
|
||||
resolveInitialChatLoadSessionKey(
|
||||
sessionKey = "session:history",
|
||||
mainSessionKey = "agent:ops:device",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user