mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
Merge branch 'main' into codex/fix-cron-legacy-migration
This commit is contained in:
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
@@ -146,12 +146,12 @@ jobs:
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
|
||||
fi
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
- name: Build CI manifest
|
||||
id: manifest
|
||||
env:
|
||||
|
||||
3
.github/workflows/opengrep-precise.yml
vendored
3
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 2
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
- name: Run opengrep on PR diff
|
||||
env:
|
||||
OPENCLAW_OPENGREP_BASE_REF: ${{ github.event.pull_request.base.sha }}...HEAD
|
||||
OPENCLAW_OPENGREP_MERGE_HEAD_FIRST_PARENT: "1"
|
||||
# Findings from precise rules block this workflow. Pull requests scan
|
||||
# changed first-party source paths only so findings stay attributable to
|
||||
# the PR diff. Test/fixture/QA path exclusions live in `.semgrepignore`
|
||||
|
||||
6
.github/workflows/tui-pty.yml
vendored
6
.github/workflows/tui-pty.yml
vendored
@@ -27,7 +27,9 @@ env:
|
||||
jobs:
|
||||
tui-pty:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -38,4 +40,4 @@ jobs:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Run TUI PTY tests
|
||||
run: timeout --kill-after=30s 120s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
|
||||
|
||||
@@ -2085,6 +2085,7 @@ class NodeRuntime(
|
||||
id = id,
|
||||
name = obj["name"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: id,
|
||||
provider = provider,
|
||||
available = obj.optionalBoolean("available"),
|
||||
supportsVision = "image" in inputTypes,
|
||||
supportsAudio = "audio" in inputTypes,
|
||||
supportsDocuments = "document" in inputTypes,
|
||||
@@ -2701,6 +2702,7 @@ data class GatewayModelSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val provider: String,
|
||||
val available: Boolean?,
|
||||
val supportsVision: Boolean,
|
||||
val supportsAudio: Boolean,
|
||||
val supportsDocuments: Boolean,
|
||||
@@ -2883,6 +2885,15 @@ 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"
|
||||
|
||||
private fun JsonObject?.optionalBoolean(key: String): Boolean? =
|
||||
(this?.get(key) as? JsonPrimitive)?.content?.trim()?.lowercase()?.let { value ->
|
||||
when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun cronJobLastRunStatus(state: JsonObject?): String? =
|
||||
state
|
||||
.cronStatus("lastStatus")
|
||||
|
||||
@@ -297,14 +297,15 @@ private fun CommandSectionLabel(title: String) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds provider quick-action metadata from current gateway/catalog state. */
|
||||
private fun providerCommandSubtitle(
|
||||
internal fun providerCommandSubtitle(
|
||||
isConnected: Boolean,
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): String {
|
||||
if (!isConnected) return "Connect Gateway to load models"
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val expiringProviderCount = expiringModelProviderCount(providers)
|
||||
if (expiringProviderCount > 0) return "$expiringProviderCount providers expiring"
|
||||
val readyProviderCount = readyModelProviderCount(providers, models)
|
||||
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
|
||||
if (models.isNotEmpty()) return "${models.size} models available"
|
||||
return "Configure model access"
|
||||
|
||||
@@ -192,6 +192,9 @@ private data class ProviderSetupRow(
|
||||
val name: String,
|
||||
val subtitle: String,
|
||||
val ready: Boolean,
|
||||
val available: Boolean,
|
||||
val statusLabel: String,
|
||||
val warning: Boolean,
|
||||
)
|
||||
|
||||
private data class ProviderRow(
|
||||
@@ -199,37 +202,60 @@ private data class ProviderRow(
|
||||
val name: String,
|
||||
val status: String,
|
||||
val ready: Boolean,
|
||||
val available: Boolean,
|
||||
val setupRequired: Boolean,
|
||||
val warning: Boolean,
|
||||
val modelCount: Int,
|
||||
)
|
||||
|
||||
/** Combines auth-provider readiness rows with catalog-only providers. */
|
||||
/** Combines auth-provider readiness rows with catalog-only browse providers. */
|
||||
private fun providerRows(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): List<ProviderRow> {
|
||||
val modelCounts = models.groupingBy { it.provider }.eachCount()
|
||||
val availableProviderIds =
|
||||
models
|
||||
.filter(::modelAvailabilityUsable)
|
||||
.map { it.provider.normalizedProviderId() }
|
||||
.toSet()
|
||||
val authRows =
|
||||
providers.map { provider ->
|
||||
val ready = modelProviderReady(provider.status)
|
||||
val providerId = provider.id.normalizedProviderId()
|
||||
val authReady = modelProviderReady(provider.status)
|
||||
val expiring = modelProviderExpiring(provider.status)
|
||||
val available = providerId in availableProviderIds
|
||||
ProviderRow(
|
||||
id = provider.id,
|
||||
name = provider.displayName,
|
||||
status = if (ready) "Ready" else "Needs setup",
|
||||
ready = ready,
|
||||
status =
|
||||
when {
|
||||
authReady -> "Ready"
|
||||
expiring -> "Expiring"
|
||||
available -> "Available"
|
||||
else -> "Needs setup"
|
||||
},
|
||||
ready = authReady,
|
||||
available = available || authReady || expiring,
|
||||
setupRequired = !authReady && !available && !expiring,
|
||||
warning = expiring,
|
||||
modelCount = modelCounts[provider.id] ?: 0,
|
||||
)
|
||||
}
|
||||
// Static/catalog-only providers may expose models without a matching auth
|
||||
// provider row; keep them visible as ready providers.
|
||||
// Catalog-only providers can be browsed but are not a readiness signal.
|
||||
val missingAuthRows =
|
||||
modelCounts.keys
|
||||
.filter { provider -> authRows.none { it.id == provider } }
|
||||
.map { provider ->
|
||||
val available = provider.normalizedProviderId() in availableProviderIds
|
||||
ProviderRow(
|
||||
id = provider,
|
||||
name = providerDisplayName(provider),
|
||||
status = "Ready",
|
||||
ready = true,
|
||||
status = if (available) "Available" else "Catalog",
|
||||
ready = available,
|
||||
available = available,
|
||||
setupRequired = false,
|
||||
warning = false,
|
||||
modelCount = modelCounts[provider] ?: 0,
|
||||
)
|
||||
}
|
||||
@@ -245,6 +271,9 @@ private fun providerSetupRows(providerRows: List<ProviderRow>): List<ProviderSet
|
||||
name = providerDisplayName(id),
|
||||
subtitle = providerSetupSubtitle(id, row),
|
||||
ready = row?.ready == true,
|
||||
available = row?.available == true,
|
||||
statusLabel = providerSetupStatusLabel(row),
|
||||
warning = row?.warning == true || row?.setupRequired == true || row == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -254,12 +283,24 @@ private fun providerSetupSubtitle(
|
||||
row: ProviderRow?,
|
||||
): String =
|
||||
when {
|
||||
row?.warning == true -> "Credential expires soon"
|
||||
row?.ready == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Ready"
|
||||
row != null -> "Finish setup to use ${row.name}"
|
||||
row?.available == true -> if (row.modelCount > 0) "${row.modelCount} models available" else "Available"
|
||||
row?.setupRequired == true -> "Finish setup to use ${row.name}"
|
||||
row != null && row.modelCount > 0 -> "${row.modelCount} catalog models"
|
||||
id == "ollama" -> "Use models running on your network"
|
||||
else -> "Add provider credentials on your Gateway"
|
||||
}
|
||||
|
||||
private fun providerSetupStatusLabel(row: ProviderRow?): String =
|
||||
when {
|
||||
row?.ready == true -> "Ready"
|
||||
row?.warning == true -> "Expiring"
|
||||
row?.available == true -> "Available"
|
||||
row?.setupRequired == false -> "Catalog"
|
||||
else -> "Setup"
|
||||
}
|
||||
|
||||
/** Normalizes gateway provider status strings into a ready/not-ready boolean. */
|
||||
internal fun modelProviderReady(status: String): Boolean {
|
||||
val normalized = status.trim().lowercase()
|
||||
@@ -270,6 +311,30 @@ internal fun modelProviderReady(status: String): Boolean {
|
||||
normalized == "static"
|
||||
}
|
||||
|
||||
private fun modelProviderExpiring(status: String): Boolean = status.trim().lowercase() == "expiring"
|
||||
|
||||
internal fun readyModelProviderCount(
|
||||
providers: List<GatewayModelProviderSummary>,
|
||||
models: List<GatewayModelSummary>,
|
||||
): Int {
|
||||
val authReadyProviders = providers.filter { modelProviderReady(it.status) }.map { it.id.normalizedProviderId() }
|
||||
val availableModelProviders = models.filter(::modelAvailabilityUsable).map { it.provider.normalizedProviderId() }
|
||||
return (authReadyProviders + availableModelProviders).distinct().size
|
||||
}
|
||||
|
||||
// Older gateways did not emit `available`; keep those rows on the legacy
|
||||
// readiness path while still honoring explicit false from upgraded gateways.
|
||||
internal fun modelAvailabilityUsable(model: GatewayModelSummary): Boolean = model.available != false
|
||||
|
||||
internal fun expiringModelProviderCount(providers: List<GatewayModelProviderSummary>): Int =
|
||||
providers
|
||||
.filter { modelProviderExpiring(it.status) }
|
||||
.map { it.id.normalizedProviderId() }
|
||||
.distinct()
|
||||
.size
|
||||
|
||||
private fun String.normalizedProviderId(): String = trim().lowercase()
|
||||
|
||||
/** Groups models by provider using the same display priority as provider rows. */
|
||||
private fun sortedModelGroups(models: List<GatewayModelSummary>): List<Pair<String, List<GatewayModelSummary>>> =
|
||||
models
|
||||
@@ -299,7 +364,18 @@ private fun ProviderList(
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column {
|
||||
if (rows.isEmpty()) {
|
||||
ProviderListRow(ProviderRow(id = "loading", name = "Provider catalog", status = if (refreshing) "Loading" else "No providers", ready = false, modelCount = 0))
|
||||
ProviderListRow(
|
||||
ProviderRow(
|
||||
id = "loading",
|
||||
name = "Provider catalog",
|
||||
status = if (refreshing) "Loading" else "No providers",
|
||||
ready = false,
|
||||
available = false,
|
||||
setupRequired = false,
|
||||
warning = false,
|
||||
modelCount = 0,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val visibleRows = rows.take(5)
|
||||
visibleRows.forEachIndexed { index, row ->
|
||||
@@ -322,12 +398,12 @@ private fun ProviderOverviewPanel(
|
||||
onRefresh: () -> Unit,
|
||||
onSetup: () -> Unit,
|
||||
) {
|
||||
val readyCount = providerRows.count { it.ready }
|
||||
val needsSetupCount = providerRows.count { !it.ready }
|
||||
val readyCount = providerRows.count { it.available }
|
||||
val needsSetupCount = providerRows.count { it.setupRequired }
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 12.dp, vertical = 12.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ProviderMetricTile(label = "Ready", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Available", value = readyCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Models", value = modelCount.toString(), modifier = Modifier.weight(1f))
|
||||
ProviderMetricTile(label = "Setup", value = needsSetupCount.toString(), modifier = Modifier.weight(1f))
|
||||
}
|
||||
@@ -398,8 +474,14 @@ private fun ProviderSetupListRow(
|
||||
Text(text = row.subtitle, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
Text(text = if (row.ready) "Ready" else "Setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
val statusColor =
|
||||
when {
|
||||
row.warning -> ClawTheme.colors.warning
|
||||
row.ready || row.available -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
}
|
||||
Box(modifier = Modifier.size(5.dp).clip(CircleShape).background(statusColor))
|
||||
Text(text = row.statusLabel, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
Icon(imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = "Open ${row.name}", modifier = Modifier.size(17.dp), tint = ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
@@ -415,7 +497,13 @@ private fun ProviderListRow(row: ProviderRow) {
|
||||
Text(text = if (row.modelCount > 0) "${row.modelCount} models" else "Provider setup", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (row.ready) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
val statusColor =
|
||||
when {
|
||||
row.warning || row.setupRequired -> ClawTheme.colors.warning
|
||||
row.ready || row.available -> ClawTheme.colors.success
|
||||
else -> ClawTheme.colors.textMuted
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusColor))
|
||||
Text(text = row.status, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textMuted, maxLines = 1)
|
||||
}
|
||||
}
|
||||
@@ -491,12 +579,13 @@ private fun ModelGroup(
|
||||
|
||||
@Composable
|
||||
private fun ModelRow(model: GatewayModelSummary) {
|
||||
val available = modelAvailabilityUsable(model)
|
||||
Row(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(text = model.name, style = ClawTheme.type.mono, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
modelCapabilityLabels(model).take(3).forEach { label ->
|
||||
ProviderMiniTag(text = label)
|
||||
}
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(ClawTheme.colors.success))
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(if (available) ClawTheme.colors.success else ClawTheme.colors.warning))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -342,7 +342,8 @@ private fun OverviewScreen(
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val readyProviderCount = readyModelProviderCount(providers, models)
|
||||
val expiringProviderCount = expiringModelProviderCount(providers)
|
||||
val attentionRows =
|
||||
homeAttentionRows(
|
||||
isConnected = isConnected,
|
||||
@@ -350,6 +351,7 @@ private fun OverviewScreen(
|
||||
channelsSummary = channelsSummary,
|
||||
nodesDevicesSummary = nodesDevicesSummary,
|
||||
readyProviderCount = readyProviderCount,
|
||||
expiringProviderCount = expiringProviderCount,
|
||||
)
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
@@ -460,6 +462,7 @@ private fun OverviewScreen(
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
expiringProviderCount > 0 -> "$expiringProviderCount expiring"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
@@ -541,6 +544,7 @@ internal fun homeAttentionRows(
|
||||
channelsSummary: GatewayChannelsSummary,
|
||||
nodesDevicesSummary: GatewayNodesDevicesSummary,
|
||||
readyProviderCount: Int,
|
||||
expiringProviderCount: Int = 0,
|
||||
): List<HomeAttentionRow> =
|
||||
listOfNotNull(
|
||||
if (!isConnected) {
|
||||
@@ -563,7 +567,12 @@ internal fun homeAttentionRows(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0) {
|
||||
if (isConnected && expiringProviderCount > 0) {
|
||||
HomeAttentionRow("Providers", "Provider auth expires soon", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0 && expiringProviderCount == 0) {
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayModelProviderSummary
|
||||
import ai.openclaw.app.GatewayModelSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
@@ -10,8 +13,129 @@ class ProviderModelStatusTest {
|
||||
assertTrue(modelProviderReady("static"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expiringProviderStatusIsNotFullyReady() {
|
||||
assertFalse(modelProviderReady("expiring"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingProviderStatusIsNotReady() {
|
||||
assertFalse(modelProviderReady("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountUsesAuthBackedProviderStatuses() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "missing", profileCount = 0),
|
||||
GatewayModelProviderSummary(id = "anthropic", displayName = "Anthropic", status = "ready", profileCount = 1),
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(providers, emptyList()))
|
||||
assertEquals(1, expiringModelProviderCount(providers))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountUsesAvailableModelsAsServingReadiness() {
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "anthropic", available = true),
|
||||
model(provider = "anthropic", available = true),
|
||||
model(provider = "openrouter", available = false),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(emptyList(), models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountDoesNotTreatCatalogOnlyModelsAsReady() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openrouter", displayName = "OpenRouter", status = "missing", profileCount = 0),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openrouter", available = false),
|
||||
)
|
||||
|
||||
assertEquals(0, readyModelProviderCount(providers, models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountPreservesLegacyRowsWhenAvailabilityIsMissing() {
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openrouter", available = null),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(emptyList(), models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountTreatsExpiringAvailableModelsAsUsableButWarnable() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openai", available = true),
|
||||
)
|
||||
|
||||
assertEquals(1, readyModelProviderCount(providers, models))
|
||||
assertEquals(1, expiringModelProviderCount(providers))
|
||||
assertFalse(modelProviderReady("expiring"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerCommandSubtitleSurfacesExpiringBeforeReadyModels() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "expiring", profileCount = 1),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openai", available = true),
|
||||
)
|
||||
|
||||
assertEquals("1 providers expiring", providerCommandSubtitle(isConnected = true, providers = providers, models = models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readyModelProviderCountDoesNotTreatUnavailableModelsAsReadyWhenAuthProviderNeedsSetup() {
|
||||
val providers =
|
||||
listOf(
|
||||
GatewayModelProviderSummary(id = "openai", displayName = "OpenAI", status = "missing", profileCount = 0),
|
||||
)
|
||||
val models =
|
||||
listOf(
|
||||
model(provider = "openai", available = false),
|
||||
)
|
||||
|
||||
assertEquals(0, readyModelProviderCount(providers, models))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelAvailabilityHonorsExplicitUnavailableRows() {
|
||||
assertTrue(modelAvailabilityUsable(model(provider = "openai", available = true)))
|
||||
assertTrue(modelAvailabilityUsable(model(provider = "openai", available = null)))
|
||||
assertFalse(modelAvailabilityUsable(model(provider = "openai", available = false)))
|
||||
}
|
||||
|
||||
private fun model(
|
||||
provider: String,
|
||||
available: Boolean?,
|
||||
): GatewayModelSummary =
|
||||
GatewayModelSummary(
|
||||
id = "$provider/test-model",
|
||||
name = "test-model",
|
||||
provider = provider,
|
||||
available = available,
|
||||
supportsVision = false,
|
||||
supportsAudio = false,
|
||||
supportsDocuments = false,
|
||||
supportsReasoning = false,
|
||||
contextTokens = null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,6 +92,21 @@ class ShellScreenLogicTest {
|
||||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceExpiringProviderAuth() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 0,
|
||||
expiringProviderCount = 1,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Provider auth expires soon"), rows.map { it.subtitle })
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
|
||||
@@ -59,6 +59,11 @@ struct SettingsProTab: View {
|
||||
@State var notificationActionText = "Request Access"
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var bottomOverlayInset: CGFloat = 0
|
||||
|
||||
var bottomScrollMargin: CGFloat {
|
||||
max(0, self.bottomOverlayInset - SettingsLayout.rowHeight - SettingsLayout.bottomContentPadding)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -71,9 +76,13 @@ struct SettingsProTab: View {
|
||||
self.gatewaySection
|
||||
self.settingsListSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, SettingsLayout.bottomContentPadding)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
.contentMargins(.bottom, self.bottomScrollMargin, for: .scrollContent)
|
||||
SettingsBottomOverlayInsetReader(inset: self.$bottomOverlayInset)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
|
||||
@@ -37,6 +37,8 @@ extension SettingsProTab {
|
||||
NavigationLink(value: SettingsRoute.gateway) {
|
||||
self.gatewayConnectionRow
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
@@ -201,9 +203,13 @@ extension SettingsProTab {
|
||||
self.aboutDestination
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, SettingsLayout.bottomContentPadding)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
.contentMargins(.bottom, self.bottomScrollMargin, for: .scrollContent)
|
||||
SettingsBottomOverlayInsetReader(inset: self.$bottomOverlayInset)
|
||||
.frame(width: 0, height: 0)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -239,11 +245,11 @@ extension SettingsProTab {
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.manualGatewayCard
|
||||
self.deviceIdentityCard
|
||||
self.agentSelectionCard
|
||||
self.gatewaySetupCard
|
||||
self.discoveredGatewaysCard
|
||||
self.manualGatewayCard
|
||||
self.gatewayAdvancedCard
|
||||
}
|
||||
}
|
||||
@@ -292,18 +298,6 @@ extension SettingsProTab {
|
||||
value: self.diagnosticsHealthValue,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
self.gatewayActionButton(
|
||||
title: "Run Diagnostics",
|
||||
@@ -316,6 +310,18 @@ extension SettingsProTab {
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
|
||||
self.diagnosticsAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Darwin
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
@@ -14,6 +15,110 @@ enum SettingsRoute: Hashable {
|
||||
enum SettingsLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let rowHeight: CGFloat = 58
|
||||
static let bottomContentPadding: CGFloat = 12
|
||||
}
|
||||
|
||||
struct SettingsBottomOverlayInsetReader: UIViewRepresentable {
|
||||
@Binding var inset: CGFloat
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(inset: self.$inset)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SettingsBottomOverlayInsetProbeView {
|
||||
let view = SettingsBottomOverlayInsetProbeView()
|
||||
view.onInsetChange = { value in
|
||||
context.coordinator.updateInset(value)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SettingsBottomOverlayInsetProbeView, context: Context) {
|
||||
context.coordinator.inset = self.$inset
|
||||
uiView.onInsetChange = { value in
|
||||
context.coordinator.updateInset(value)
|
||||
}
|
||||
uiView.updateInset()
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var inset: Binding<CGFloat>
|
||||
|
||||
init(inset: Binding<CGFloat>) {
|
||||
self.inset = inset
|
||||
}
|
||||
|
||||
func updateInset(_ value: CGFloat) {
|
||||
let rounded = max(0, ceil(value))
|
||||
guard abs(self.inset.wrappedValue - rounded) > 0.5 else { return }
|
||||
self.inset.wrappedValue = rounded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsBottomOverlayInsetProbeView: UIView {
|
||||
var onInsetChange: ((CGFloat) -> Void)?
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
self.updateInset()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
self.updateInset()
|
||||
}
|
||||
|
||||
func updateInset() {
|
||||
let value = self.visibleTabBarHeight()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onInsetChange?(value)
|
||||
}
|
||||
}
|
||||
|
||||
private func visibleTabBarHeight() -> CGFloat {
|
||||
let tabBarController = self.nearestViewController()?.tabBarController
|
||||
?? self.findTabBarController(in: self.window?.rootViewController)
|
||||
guard let tabBar = tabBarController?.tabBar,
|
||||
!tabBar.isHidden,
|
||||
tabBar.alpha > 0.01,
|
||||
tabBar.window != nil,
|
||||
self.window != nil
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let tabFrame = tabBar.convert(tabBar.bounds, to: nil)
|
||||
guard tabFrame.height.isFinite else { return 0 }
|
||||
return max(0, tabFrame.height)
|
||||
}
|
||||
|
||||
private func nearestViewController() -> UIViewController? {
|
||||
var responder: UIResponder? = self
|
||||
while let current = responder {
|
||||
if let viewController = current as? UIViewController {
|
||||
return viewController
|
||||
}
|
||||
responder = current.next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func findTabBarController(in viewController: UIViewController?) -> UITabBarController? {
|
||||
guard let viewController else { return nil }
|
||||
if let tabBarController = viewController as? UITabBarController {
|
||||
return tabBarController
|
||||
}
|
||||
if let tabBarController = self.findTabBarController(in: viewController.presentedViewController) {
|
||||
return tabBarController
|
||||
}
|
||||
for child in viewController.children {
|
||||
if let tabBarController = self.findTabBarController(in: child) {
|
||||
return tabBarController
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
|
||||
@@ -15,6 +15,7 @@ struct TalkProTab: View {
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
isEnabled: self.appModel.talkMode.isEnabled || self.talkEnabled,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
isConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
@@ -282,6 +283,9 @@ struct TalkProTab: View {
|
||||
if self.state
|
||||
.prefersPermissionCopy { return "Gateway approval is required before this phone can capture voice." }
|
||||
if !self.gatewayConnected { return "Connect to your gateway to start a voice conversation." }
|
||||
if !self.appModel.talkMode.gatewayTalkConfigLoaded {
|
||||
return "Open Voice settings after the gateway loads Talk configuration."
|
||||
}
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkVoiceModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !subtitle.isEmpty { return subtitle }
|
||||
@@ -365,6 +369,7 @@ struct TalkProState: Equatable {
|
||||
let gatewayConnected: Bool
|
||||
let isEnabled: Bool
|
||||
let statusText: String
|
||||
let isConfigLoaded: Bool
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
@@ -390,6 +395,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "Voice config unavailable" }
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
@@ -412,6 +418,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "Config" }
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.isEnabled { return "Ready" }
|
||||
@@ -432,6 +439,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return "exclamationmark.triangle.fill" }
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
@@ -447,6 +455,7 @@ struct TalkProState: Equatable {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .apiKeyMissing:
|
||||
return OpenClawBrand.warn
|
||||
default:
|
||||
if !self.isConfigLoaded { return OpenClawBrand.warn }
|
||||
return self.isEnabled ? OpenClawBrand.ok : OpenClawBrand.accentHot
|
||||
}
|
||||
}
|
||||
@@ -518,6 +527,7 @@ struct TalkProState: Equatable {
|
||||
default:
|
||||
break
|
||||
}
|
||||
if !self.isConfigLoaded { return .still }
|
||||
if self.isSpeaking { return .speaking }
|
||||
if self.isListening, self.isUserSpeechDetected { return .inputSpeech }
|
||||
if self.isListening { return .level(micLevel) }
|
||||
|
||||
@@ -702,6 +702,9 @@ final class GatewayConnectionController {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
Task { [weak self, weak appModel] in
|
||||
guard let self, let appModel else { return }
|
||||
if forceReconnect {
|
||||
await appModel.resetGatewaySessionsForForcedReconnect()
|
||||
}
|
||||
let nodeOptions = await self.makeConnectOptions(stableID: gatewayStableID)
|
||||
let cfg = GatewayConnectConfig(
|
||||
url: url,
|
||||
|
||||
@@ -1034,18 +1034,18 @@ final class NodeAppModel {
|
||||
OpenClawCanvasPresentParams()
|
||||
let url = params.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if url.isEmpty {
|
||||
self.screen.showDefaultCanvas()
|
||||
self.screen.presentDefaultCanvas()
|
||||
} else {
|
||||
self.screen.navigate(to: url)
|
||||
self.screen.present(urlString: url)
|
||||
}
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case OpenClawCanvasCommand.hide.rawValue:
|
||||
self.screen.showDefaultCanvas()
|
||||
self.screen.hideCanvas()
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case OpenClawCanvasCommand.navigate.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawCanvasNavigateParams.self, from: req.paramsJSON)
|
||||
let trimmedURL = params.url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.screen.navigate(to: trimmedURL)
|
||||
self.screen.present(urlString: trimmedURL)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true)
|
||||
case OpenClawCanvasCommand.evalJS.rawValue:
|
||||
let params = try Self.decodeParams(OpenClawCanvasEvalParams.self, from: req.paramsJSON)
|
||||
@@ -1939,6 +1939,15 @@ extension NodeAppModel {
|
||||
forceReconnect: forceReconnect)
|
||||
}
|
||||
|
||||
func resetGatewaySessionsForForcedReconnect() async {
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
}
|
||||
|
||||
func disconnectGateway() {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
@@ -4557,6 +4566,10 @@ extension NodeAppModel {
|
||||
self.clearingBootstrapToken(in: config)
|
||||
}
|
||||
|
||||
func _test_hasGatewayLoopTasks() -> (node: Bool, operator: Bool) {
|
||||
(self.nodeGatewayTask != nil, self.operatorGatewayTask != nil)
|
||||
}
|
||||
|
||||
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
|
||||
@@ -200,6 +200,36 @@ struct RootTabs: View {
|
||||
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if self.appModel.screen.isCanvasPresented {
|
||||
self.canvasPresentationOverlay
|
||||
.transition(.opacity)
|
||||
.zIndex(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var canvasPresentationOverlay: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Color.black.ignoresSafeArea()
|
||||
ScreenWebView(controller: self.appModel.screen)
|
||||
.ignoresSafeArea()
|
||||
Button {
|
||||
self.appModel.screen.hideCanvas()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.white)
|
||||
.shadow(color: .black.opacity(0.32), radius: 8, y: 2)
|
||||
.frame(width: 48, height: 48)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close canvas")
|
||||
.safeAreaPadding(.top, 8)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private func rootLifecycle(_ content: some View) -> some View {
|
||||
|
||||
@@ -10,6 +10,7 @@ final class ScreenController {
|
||||
|
||||
var urlString: String = ""
|
||||
var errorText: String?
|
||||
var isCanvasPresented: Bool = false
|
||||
|
||||
/// Callback invoked when an openclaw:// deep link is tapped in the canvas
|
||||
var onDeepLink: ((URL) -> Void)?
|
||||
@@ -75,7 +76,23 @@ final class ScreenController {
|
||||
self.reload()
|
||||
}
|
||||
|
||||
func presentDefaultCanvas() {
|
||||
self.isCanvasPresented = true
|
||||
self.showDefaultCanvas()
|
||||
}
|
||||
|
||||
func present(urlString: String) {
|
||||
self.isCanvasPresented = true
|
||||
self.navigate(to: urlString)
|
||||
}
|
||||
|
||||
func hideCanvas() {
|
||||
self.isCanvasPresented = false
|
||||
self.showDefaultCanvas()
|
||||
}
|
||||
|
||||
func showLocalA2UI() {
|
||||
self.isCanvasPresented = true
|
||||
guard let url = Self.localA2UIURL else {
|
||||
self.showDefaultCanvas()
|
||||
return
|
||||
|
||||
@@ -235,6 +235,20 @@ import UIKit
|
||||
#expect(appModel.connectedGatewayID == second.stableID)
|
||||
}
|
||||
|
||||
@Test @MainActor func forcedReconnectResetClearsActiveGatewayLoopTasks() async {
|
||||
let appModel = NodeAppModel()
|
||||
defer { appModel.disconnectGateway() }
|
||||
|
||||
appModel.applyGatewayConnectConfig(Self.makeGatewayConnectConfig())
|
||||
#expect(appModel._test_hasGatewayLoopTasks().node)
|
||||
#expect(appModel._test_hasGatewayLoopTasks().operator)
|
||||
|
||||
await appModel.resetGatewaySessionsForForcedReconnect()
|
||||
|
||||
#expect(!appModel._test_hasGatewayLoopTasks().node)
|
||||
#expect(!appModel._test_hasGatewayLoopTasks().operator)
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
|
||||
@@ -45,6 +45,23 @@ private func mountScreen(_ screen: ScreenController) throws -> (ScreenWebViewCoo
|
||||
#expect(screen.urlString.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func canvasPresentationTracksExplicitPresentAndHide() {
|
||||
let screen = ScreenController()
|
||||
|
||||
#expect(screen.isCanvasPresented == false)
|
||||
|
||||
screen.showDefaultCanvas()
|
||||
#expect(screen.isCanvasPresented == false)
|
||||
|
||||
screen.presentDefaultCanvas()
|
||||
#expect(screen.isCanvasPresented == true)
|
||||
#expect(screen.urlString.isEmpty)
|
||||
|
||||
screen.hideCanvas()
|
||||
#expect(screen.isCanvasPresented == false)
|
||||
#expect(screen.urlString.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func evalExecutesJavaScript() async throws {
|
||||
let screen = ScreenController()
|
||||
let (coordinator, _) = try mountScreen(screen)
|
||||
|
||||
73
apps/ios/Tests/TalkProStateTests.swift
Normal file
73
apps/ios/Tests/TalkProStateTests.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct TalkProStateTests {
|
||||
@Test func disabledTalkWithoutLoadedConfigCanStartAndRetryLoad() {
|
||||
let state = TalkProState(
|
||||
gatewayConnected: true,
|
||||
isEnabled: false,
|
||||
statusText: "Offline",
|
||||
isConfigLoaded: false,
|
||||
isListening: false,
|
||||
isSpeaking: false,
|
||||
isUserSpeechDetected: false,
|
||||
permissionState: .unknown)
|
||||
|
||||
#expect(state.title == "Voice config unavailable")
|
||||
#expect(state.chipText == "Config")
|
||||
#expect(state.primaryAction == .start)
|
||||
#expect(state.primaryButtonTitle == "Start Talk")
|
||||
#expect(state.waveformMode(micLevel: 0.8) == .still)
|
||||
}
|
||||
|
||||
@Test func enabledTalkWithoutLoadedConfigCanBeStopped() {
|
||||
let state = TalkProState(
|
||||
gatewayConnected: true,
|
||||
isEnabled: true,
|
||||
statusText: "Offline",
|
||||
isConfigLoaded: false,
|
||||
isListening: false,
|
||||
isSpeaking: false,
|
||||
isUserSpeechDetected: false,
|
||||
permissionState: .unknown)
|
||||
|
||||
#expect(state.title == "Voice config unavailable")
|
||||
#expect(state.chipText == "Config")
|
||||
#expect(state.primaryAction == .stop)
|
||||
#expect(state.primaryButtonTitle == "Stop Talk")
|
||||
#expect(state.waveformMode(micLevel: 0.8) == .still)
|
||||
}
|
||||
|
||||
@Test func enabledTalkWithLoadedConfigCanBeStopped() {
|
||||
let state = TalkProState(
|
||||
gatewayConnected: true,
|
||||
isEnabled: true,
|
||||
statusText: "Ready",
|
||||
isConfigLoaded: true,
|
||||
isListening: false,
|
||||
isSpeaking: false,
|
||||
isUserSpeechDetected: false,
|
||||
permissionState: .ready)
|
||||
|
||||
#expect(state.title == "Ready to talk")
|
||||
#expect(state.chipText == "Ready")
|
||||
#expect(state.primaryAction == .stop)
|
||||
}
|
||||
|
||||
@Test func missingScopeTakesPriorityOverUnloadedConfig() {
|
||||
let state = TalkProState(
|
||||
gatewayConnected: true,
|
||||
isEnabled: false,
|
||||
statusText: "Offline",
|
||||
isConfigLoaded: false,
|
||||
isListening: false,
|
||||
isSpeaking: false,
|
||||
isUserSpeechDetected: false,
|
||||
permissionState: .missingScope("operator.talk.secrets"))
|
||||
|
||||
#expect(state.title == "Gateway permission required")
|
||||
#expect(state.chipText == "Needs approval")
|
||||
#expect(state.primaryAction == .enablePermission)
|
||||
#expect(state.primaryButtonTitle == "Enable Talk")
|
||||
}
|
||||
}
|
||||
@@ -201,6 +201,7 @@ struct OpenClawChatComposer: View {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.accessibilityLabel("Attachments")
|
||||
.buttonStyle(.plain)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
@@ -210,6 +211,7 @@ struct OpenClawChatComposer: View {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.accessibilityLabel("Attachments")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
@@ -219,6 +221,7 @@ struct OpenClawChatComposer: View {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.accessibilityLabel("Attachments")
|
||||
.buttonStyle(.plain)
|
||||
.controlSize(.small)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
@@ -229,6 +232,7 @@ struct OpenClawChatComposer: View {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.accessibilityLabel("Attachments")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bundled A2UI runtime resource embedded by OpenClawKit.
|
||||
var __defProp$1 = Object.defineProperty;
|
||||
var __exportAll = (all, no_symbols) => {
|
||||
let target = {};
|
||||
|
||||
@@ -4677,6 +4677,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
public let name: String
|
||||
public let provider: String
|
||||
public let alias: String?
|
||||
public let available: Bool?
|
||||
public let contextwindow: Int?
|
||||
public let reasoning: Bool?
|
||||
|
||||
@@ -4685,6 +4686,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
name: String,
|
||||
provider: String,
|
||||
alias: String?,
|
||||
available: Bool? = nil,
|
||||
contextwindow: Int?,
|
||||
reasoning: Bool?)
|
||||
{
|
||||
@@ -4692,6 +4694,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
self.name = name
|
||||
self.provider = provider
|
||||
self.alias = alias
|
||||
self.available = available
|
||||
self.contextwindow = contextwindow
|
||||
self.reasoning = reasoning
|
||||
}
|
||||
@@ -4701,6 +4704,7 @@ public struct ModelChoice: Codable, Sendable {
|
||||
case name
|
||||
case provider
|
||||
case alias
|
||||
case available
|
||||
case contextwindow = "contextWindow"
|
||||
case reasoning
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
e3b8988a10c61dbf0a78a70bca9ef1ab43c6a58aeaa5ef9f8699f34b6dae4c9d config-baseline.json
|
||||
a2f53abfe6bbe8b1ddfa5548f555704d8ff0cdd48bcb5780d66499bec0b7775a config-baseline.core.json
|
||||
3d0f7723873da553f25dfe6892a586d774fa36e447de487eba4dd3e0a012f877 config-baseline.channel.json
|
||||
60c0700719fd2fe3f7cec4c35da10227b681d87ed1a3876ef830eb6bd80d43f2 config-baseline.json
|
||||
2ed21fa4a416ac2cec55eb2b6d1b11859aa04b40bd78c6ed9f3eb45b7240261c config-baseline.core.json
|
||||
0637c9bdcb9517f56049dd786563366877458d35df575328a6b80a890c8bc915 config-baseline.channel.json
|
||||
e6a1d6f51f0d9c04bd92d51deebfaca8c7917dd28d7998d225c0074e0a095348 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
9ce72d763de6c95566e0167f99f5454b07c7c67940675533cb24c07058619a63 plugin-sdk-api-baseline.json
|
||||
e4dfccb85b985fe865145e24978255b729cdcbca0e26650a363a11bfcfc2e27b plugin-sdk-api-baseline.jsonl
|
||||
a3ab01b572937539e563aa320ad80135bc701e20fffc43c0351d799590b7a0e0 plugin-sdk-api-baseline.json
|
||||
9d49587923f8fc4abb16d981bcab54acbf90a3e74ab05933761049e2da0cffe1 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -162,6 +162,7 @@ Configure your tunnel's ingress rules to only route the webhook path:
|
||||
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
|
||||
- `openclaw pairing approve googlechat <code>`
|
||||
5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app's user name.
|
||||
6. When an exec or plugin approval request starts from Google Chat and a stable `users/<id>` approver is configured, OpenClaw posts a native Google Chat approval card in the originating space or thread. The card buttons use opaque callback tokens, and the manual `/approve <id> <decision>` prompt is only shown when native approval delivery is unavailable.
|
||||
|
||||
## Targets
|
||||
|
||||
@@ -214,8 +215,9 @@ Notes:
|
||||
- Default webhook path is `/googlechat` if `webhookPath` isn't set.
|
||||
- `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode).
|
||||
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
|
||||
- Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `dm.allowFrom` or `defaultTo` and must be stable numeric `users/<id>` values.
|
||||
- Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting.
|
||||
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
|
||||
- `typingIndicator` supports `message` (default), `none`, and `reaction` (reaction requires user OAuth).
|
||||
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
|
||||
- Bot-authored Google Chat messages are ignored by default. If you intentionally set `allowBots: true`, accepted bot-authored messages use shared [bot loop protection](/channels/bot-loop-protection). Configure `channels.defaults.botLoopProtection`, then override with `channels.googlechat.botLoopProtection` or `channels.googlechat.groups.<space>.botLoopProtection` when one space needs a different budget.
|
||||
|
||||
|
||||
@@ -232,6 +232,20 @@ Notes:
|
||||
- Tool-progress preview updates are enabled by default when Matrix preview streaming is active. Set `streaming.preview.toolProgress: false` to keep preview edits for answer text but leave tool progress on the normal delivery path.
|
||||
- Preview edits cost extra Matrix API calls. Leave `streaming: "off"` if you want the most conservative rate-limit profile.
|
||||
|
||||
## Voice messages
|
||||
|
||||
Inbound Matrix voice notes are transcribed before the room mention gate. This lets a voice note that says the bot name trigger the agent in a `requireMention: true` room, and it gives the agent the transcript instead of only an audio attachment placeholder.
|
||||
|
||||
Matrix uses the shared audio media provider configured under `tools.media.audio`, such as OpenAI `gpt-4o-mini-transcribe`. See [Media tools overview](/tools/media-overview) for provider setup and limits.
|
||||
|
||||
Behavior details:
|
||||
|
||||
- `m.audio` events and `m.file` events with an `audio/*` MIME type are eligible.
|
||||
- In encrypted rooms, OpenClaw decrypts the attachment through the existing Matrix media path before transcription.
|
||||
- The transcript is marked as machine-generated and untrusted in the agent prompt.
|
||||
- The attachment is marked as already transcribed so downstream media tools do not transcribe the same voice note again.
|
||||
- Set `tools.media.audio.enabled: false` to disable audio transcription globally.
|
||||
|
||||
## Approval metadata
|
||||
|
||||
Matrix native approval prompts are normal `m.room.message` events with OpenClaw-specific custom event content under `com.openclaw.approval`. Matrix permits custom event-content keys, so stock clients still render the text body while OpenClaw-aware clients can read the structured approval id, kind, state, available decisions, and exec/plugin details.
|
||||
|
||||
@@ -397,7 +397,7 @@ Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`, planner
|
||||
| `OPENCLAW_DOCKER_ALL_PARALLELISM` | 10 | Main-pool slot count for normal lanes. |
|
||||
| `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM` | 10 | Provider-sensitive tail-pool slot count. |
|
||||
| `OPENCLAW_DOCKER_ALL_LIVE_LIMIT` | 9 | Concurrent live lane cap so providers do not throttle. |
|
||||
| `OPENCLAW_DOCKER_ALL_NPM_LIMIT` | 10 | Concurrent npm install lane cap. |
|
||||
| `OPENCLAW_DOCKER_ALL_NPM_LIMIT` | 5 | Concurrent npm install lane cap. |
|
||||
| `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT` | 7 | Concurrent multi-service lane cap. |
|
||||
| `OPENCLAW_DOCKER_ALL_START_STAGGER_MS` | 2000 | Stagger between lane starts to avoid Docker daemon create storms; set `0` for no stagger. |
|
||||
| `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS` | 7200000 | Per-lane fallback timeout (120 minutes); selected live/tail lanes use tighter caps. |
|
||||
|
||||
@@ -70,6 +70,10 @@ present.
|
||||
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
|
||||
vector readiness probes and embedding maintenance in that mode. If a mode
|
||||
fails, OpenClaw retries with `qmd query`.
|
||||
- When `searchMode` is `query`, set `memory.qmd.rerank` to `false` to use QMD's
|
||||
hybrid query path without the reranker. OpenClaw passes `--no-rerank` to the
|
||||
direct QMD CLI path and `rerank: false` to QMD's MCP query tool. This option
|
||||
requires QMD 2.1 or newer.
|
||||
- With QMD releases that advertise multi-collection filters, OpenClaw groups
|
||||
same-source collections into one QMD search invocation. Older QMD releases
|
||||
keep the compatible per-collection fallback.
|
||||
|
||||
@@ -306,7 +306,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M3` |
|
||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | `mistral/mistral-large-latest` |
|
||||
| Moonshot | `moonshot` | `MOONSHOT_API_KEY` | `moonshot/kimi-k2.6` |
|
||||
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-super-120b-a12b` |
|
||||
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-ultra-550b-a55b` |
|
||||
| NovitaAI | `novita` | `NOVITA_API_KEY` | `novita/deepseek/deepseek-v3-0324` |
|
||||
| [Ollama Cloud](/providers/ollama-cloud) | `ollama-cloud` | `OLLAMA_API_KEY` | `ollama-cloud/kimi-k2.6` |
|
||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
|
||||
@@ -387,7 +387,7 @@ On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabl
|
||||
|
||||
On the native Codex harness, `HEARTBEAT.md` content is not injected into the turn. If the file exists and has non-whitespace content, the heartbeat collaboration-mode instructions point Codex at the file and tell it to read before proceeding.
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||
|
||||
|
||||
@@ -691,7 +691,7 @@ Look for:
|
||||
- `cron: scheduler disabled; jobs will not run automatically` → cron disabled.
|
||||
- `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors.
|
||||
- `heartbeat skipped` with `reason=quiet-hours` → outside active hours window.
|
||||
- `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank lines / markdown headers, so OpenClaw skips the model call.
|
||||
- `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding, so OpenClaw skips the model call.
|
||||
- `heartbeat skipped` with `reason=no-tasks-due` → `HEARTBEAT.md` contains a `tasks:` block, but none of the tasks are due on this tick.
|
||||
- `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target.
|
||||
- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`.
|
||||
|
||||
@@ -67,7 +67,7 @@ and troubleshooting see the main [FAQ](/help/faq).
|
||||
Common heartbeat skip reasons:
|
||||
|
||||
- `quiet-hours`: outside the configured active-hours window
|
||||
- `empty-heartbeat-file`: `HEARTBEAT.md` exists but only contains blank/header-only scaffolding
|
||||
- `empty-heartbeat-file`: `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding
|
||||
- `no-tasks-due`: `HEARTBEAT.md` task mode is active but none of the task intervals are due yet
|
||||
- `alerts-disabled`: all heartbeat visibility is disabled (`showOk`, `showAlerts`, and `useIndicator` are all off)
|
||||
|
||||
|
||||
@@ -1350,8 +1350,9 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls.
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines,
|
||||
Markdown/HTML comments, Markdown headings like `# Heading`, fence markers,
|
||||
or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||
|
||||
@@ -746,7 +746,7 @@ These Docker runners split into two buckets:
|
||||
`OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=45000`, and
|
||||
`OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=90000`. Set `OPENCLAW_LIVE_MAX_MODELS`
|
||||
or the gateway env vars when you explicitly want a smaller cap or larger scan.
|
||||
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials.
|
||||
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, packs OpenClaw once as an npm tarball through `scripts/package-openclaw-for-docker.mjs`, then builds/reuses two `scripts/e2e/Dockerfile` images. The bare image is only the Node/Git runner for install/update/plugin-dependency lanes; those lanes mount the prebuilt tarball. The functional image installs the same tarball into `/app` for built-app functionality lanes. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. The aggregate uses a weighted local scheduler: `OPENCLAW_DOCKER_ALL_PARALLELISM` controls process slots, while resource caps keep heavy live, npm-install, and multi-service lanes from all starting at once. If a single lane is heavier than the active caps, the scheduler can still start it when the pool is empty and then keeps it running alone until capacity is available again. Defaults are 10 slots, `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=5`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; tune `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` only when the Docker host has more headroom. The runner performs a Docker preflight by default, removes stale OpenClaw E2E containers, prints status every 30 seconds, stores successful lane timings in `.artifacts/docker-tests/lane-timings.json`, and uses those timings to start longer lanes first on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the weighted lane manifest without building or running Docker, or `node scripts/test-docker-all.mjs --plan-json` to print the CI plan for selected lanes, package/image needs, and credentials.
|
||||
- `Package Acceptance` is the GitHub-native package gate for "does this installable tarball work as a product?" It resolves one candidate package from `source=npm`, `source=ref`, `source=url`, or `source=artifact`, uploads it as `package-under-test`, then runs the reusable Docker E2E lanes against that exact tarball instead of repacking the selected ref. Profiles are ordered by breadth: `smoke`, `package`, `product`, and `full`. See [Testing updates and plugins](/help/testing-updates-plugins) for the package/update/plugin contract, published-upgrade survivor matrix, release defaults, and failure triage.
|
||||
- Build and release checks run `scripts/check-cli-bootstrap-imports.mjs` after tsdown. The guard walks the static built graph from `dist/entry.js` and `dist/cli/run-main.js` and fails if pre-dispatch startup imports package dependencies such as Commander, prompt UI, undici, or logging before command dispatch; it also keeps the bundled gateway run chunk under budget and rejects static imports of known cold gateway paths. Packaged CLI smoke also covers root help, onboard help, doctor help, status, config schema, and a model-list command.
|
||||
- Package Acceptance legacy compatibility is capped at `2026.4.25` (`2026.4.25-beta.*` included). Through that cutoff, the harness tolerates only shipped-package metadata gaps: omitted private QA inventory entries, missing `gateway install --wrapper`, missing patch files in the tarball-derived git fixture, missing persisted `update.channel`, legacy plugin install-record locations, missing marketplace install-record persistence, and config metadata migration during `plugins update`. For packages after `2026.4.25`, those paths are strict failures.
|
||||
|
||||
@@ -364,7 +364,7 @@ flowchart TD
|
||||
|
||||
- `cron: scheduler disabled; jobs will not run automatically` → cron is disabled.
|
||||
- `heartbeat skipped` with `reason=quiet-hours` → outside configured active hours.
|
||||
- `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank/header-only scaffolding.
|
||||
- `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding.
|
||||
- `heartbeat skipped` with `reason=no-tasks-due` → `HEARTBEAT.md` task mode is active but none of the task intervals are due yet.
|
||||
- `heartbeat skipped` with `reason=alerts-disabled` → all heartbeat visibility is disabled (`showOk`, `showAlerts`, and `useIndicator` are all off).
|
||||
- `requests-in-flight` → main lane busy; heartbeat wake was deferred.
|
||||
|
||||
@@ -3,12 +3,15 @@ summary: "Use NVIDIA's OpenAI-compatible API in OpenClaw"
|
||||
read_when:
|
||||
- You want to use open models in OpenClaw for free
|
||||
- You need NVIDIA_API_KEY setup
|
||||
- You want to use Nemotron 3 Ultra through NVIDIA
|
||||
title: "NVIDIA"
|
||||
---
|
||||
|
||||
NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for
|
||||
open models for free. Authenticate with an API key from
|
||||
[build.nvidia.com](https://build.nvidia.com/settings/api-keys).
|
||||
[build.nvidia.com](https://build.nvidia.com/settings/api-keys). OpenClaw
|
||||
defaults the NVIDIA provider to Nemotron 3 Ultra, NVIDIA's 550B total / 55B
|
||||
active reasoning model for long-context agentic work.
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -24,7 +27,7 @@ open models for free. Authenticate with an API key from
|
||||
</Step>
|
||||
<Step title="Set an NVIDIA model">
|
||||
```bash
|
||||
openclaw models set nvidia/nvidia/nemotron-3-super-120b-a12b
|
||||
openclaw models set nvidia/nvidia/nemotron-3-ultra-550b-a55b
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -56,7 +59,7 @@ openclaw onboard --auth-choice nvidia-api-key --nvidia-api-key "nvapi-..."
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "nvidia/nvidia/nemotron-3-super-120b-a12b" },
|
||||
model: { primary: "nvidia/nvidia/nemotron-3-ultra-550b-a55b" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -69,22 +72,39 @@ try NVIDIA's public featured-model catalog from
|
||||
`https://assets.ngc.nvidia.com/products/api-catalog/featured-models.json` and
|
||||
caches the ranked result for 24 hours. New featured models from build.nvidia.com
|
||||
therefore appear in setup and model-selection surfaces without waiting for an
|
||||
OpenClaw release.
|
||||
OpenClaw release. When the live feed is available, the first returned model is
|
||||
the default option shown during NVIDIA setup.
|
||||
|
||||
The fetch uses a fixed HTTPS host policy for `assets.ngc.nvidia.com`. If no
|
||||
NVIDIA API key is configured, or if that public catalog is unavailable or
|
||||
malformed, OpenClaw falls back to the bundled catalog below.
|
||||
malformed, OpenClaw falls back to the bundled catalog and bundled default below.
|
||||
|
||||
## Nemotron 3 Ultra
|
||||
|
||||
Nemotron 3 Ultra is the default NVIDIA model in OpenClaw. NVIDIA's build page for
|
||||
[`nvidia/nemotron-3-ultra-550b-a55b`](https://build.nvidia.com/nvidia/nemotron-3-ultra-550b-a55b)
|
||||
lists it as an available free endpoint with a 1M-token context specification.
|
||||
The bundled catalog records a 16,384-token max output to match NVIDIA's current
|
||||
OpenAI-compatible sample request for the hosted endpoint.
|
||||
|
||||
Use Ultra for the highest-capability NVIDIA default. Keep Super selected when
|
||||
you want the smaller Nemotron 3 option, or choose one of the third-party models
|
||||
hosted in NVIDIA's catalog when their context, latency, or behavior fits better.
|
||||
The bundled Ultra row sends `chat_template_kwargs.enable_thinking: false` and
|
||||
`force_nonempty_content: true` by default so normal chat output stays in the
|
||||
visible answer instead of exposing reasoning text.
|
||||
|
||||
## Bundled fallback catalog
|
||||
|
||||
| Model ref | Name | Context | Max output | Notes |
|
||||
| ------------------------------------------ | ---------------------------- | ------- | ---------- | --------------------------------- |
|
||||
| `nvidia/nvidia/nemotron-3-super-120b-a12b` | NVIDIA Nemotron 3 Super 120B | 262,144 | 8,192 | Featured fallback |
|
||||
| `nvidia/moonshotai/kimi-k2.5` | Kimi K2.5 | 262,144 | 8,192 | Featured fallback |
|
||||
| `nvidia/minimaxai/minimax-m2.7` | Minimax M2.7 | 196,608 | 8,192 | Featured fallback |
|
||||
| `nvidia/z-ai/glm-5.1` | GLM 5.1 | 202,752 | 8,192 | Featured fallback |
|
||||
| `nvidia/minimaxai/minimax-m2.5` | MiniMax M2.5 | 196,608 | 8,192 | Deprecated, upgrade compatibility |
|
||||
| `nvidia/z-ai/glm5` | GLM-5 | 202,752 | 8,192 | Deprecated, upgrade compatibility |
|
||||
| Model ref | Name | Context | Max output | Notes |
|
||||
| ------------------------------------------ | ---------------------------- | --------- | ---------- | --------------------------------- |
|
||||
| `nvidia/nvidia/nemotron-3-ultra-550b-a55b` | NVIDIA Nemotron 3 Ultra 550B | 1,000,000 | 16,384 | Default |
|
||||
| `nvidia/nvidia/nemotron-3-super-120b-a12b` | NVIDIA Nemotron 3 Super 120B | 262,144 | 8,192 | Featured fallback |
|
||||
| `nvidia/moonshotai/kimi-k2.5` | Kimi K2.5 | 262,144 | 8,192 | Featured fallback |
|
||||
| `nvidia/minimaxai/minimax-m2.7` | Minimax M2.7 | 196,608 | 8,192 | Featured fallback |
|
||||
| `nvidia/z-ai/glm-5.1` | GLM 5.1 | 202,752 | 8,192 | Featured fallback |
|
||||
| `nvidia/minimaxai/minimax-m2.5` | MiniMax M2.5 | 196,608 | 8,192 | Deprecated, upgrade compatibility |
|
||||
| `nvidia/z-ai/glm5` | GLM-5 | 202,752 | 8,192 | Deprecated, upgrade compatibility |
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
@@ -97,9 +117,9 @@ malformed, OpenClaw falls back to the bundled catalog below.
|
||||
<Accordion title="Catalog and pricing">
|
||||
OpenClaw prefers NVIDIA's public featured-model catalog when NVIDIA auth is
|
||||
configured and caches it for 24 hours. The bundled fallback catalog is static
|
||||
and keeps deprecated shipped refs for upgrade compatibility. Costs default to
|
||||
`0` in source since NVIDIA currently offers free API access for the listed
|
||||
models.
|
||||
and keeps deprecated shipped refs for upgrade compatibility. Costs default
|
||||
to `0` in source since NVIDIA currently offers free API access for the
|
||||
listed models.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenAI-compatible endpoint">
|
||||
@@ -107,6 +127,36 @@ malformed, OpenClaw falls back to the bundled catalog below.
|
||||
tooling should work out of the box with the NVIDIA base URL.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Nemotron 3 Ultra reasoning params">
|
||||
NVIDIA's Ultra sample request uses `chat_template_kwargs.enable_thinking`
|
||||
and `reasoning_budget` for reasoning output. OpenClaw's bundled Ultra row
|
||||
disables template thinking by default for normal chat use. If you need to
|
||||
opt into NVIDIA reasoning output or force other NVIDIA-specific request
|
||||
fields, set per-model params and keep provider-specific overrides scoped to
|
||||
the NVIDIA model:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"nvidia/nvidia/nemotron-3-ultra-550b-a55b": {
|
||||
params: {
|
||||
chat_template_kwargs: { enable_thinking: true },
|
||||
extra_body: { reasoning_budget: 16384 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`params.extra_body` is the final OpenAI-compatible request-body override, so
|
||||
use it only for fields NVIDIA documents for the selected endpoint.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Slow custom provider responses">
|
||||
Some NVIDIA-hosted custom models can take longer than the default model idle
|
||||
watchdog before they emit a first response chunk. For custom NVIDIA provider
|
||||
|
||||
@@ -467,6 +467,7 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd`
|
||||
| ------------------------ | --------- | -------- | ------------------------------------------------------------------------------------- |
|
||||
| `command` | `string` | `qmd` | QMD executable path; set an absolute path when service `PATH` differs from your shell |
|
||||
| `searchMode` | `string` | `search` | Search command: `search`, `vsearch`, `query` |
|
||||
| `rerank` | `boolean` | -- | Set to `false` with `searchMode: "query"` and QMD 2.1+ to skip QMD reranking |
|
||||
| `includeDefaultMemory` | `boolean` | `true` | Auto-index `MEMORY.md` + `memory/**/*.md` |
|
||||
| `paths[]` | `array` | -- | Extra paths: `{ name, path, pattern? }` |
|
||||
| `sessions.enabled` | `boolean` | `false` | Index session transcripts |
|
||||
@@ -475,6 +476,8 @@ Set `memory.backend = "qmd"` to enable. All QMD settings live under `memory.qmd`
|
||||
|
||||
`searchMode: "search"` is lexical/BM25-only. OpenClaw does not run semantic vector readiness probes or QMD embedding maintenance for that mode, including during `memory status --deep`; `vsearch` and `query` continue to require QMD vector readiness and embeddings.
|
||||
|
||||
`rerank: false` only changes QMD `query` mode and requires QMD 2.1 or newer. In direct CLI mode OpenClaw passes `--no-rerank`; in mcporter-backed MCP mode it passes `rerank: false` to QMD's unified query tool. Leave it unset to use QMD's default query reranking behavior.
|
||||
|
||||
OpenClaw prefers current QMD collection and MCP query shapes, but keeps older QMD releases working by trying compatible collection pattern flags and older MCP tool names when needed. When QMD advertises support for multiple collection filters, same-source collections are searched with one QMD process; older QMD builds keep the per-collection compatibility path. Same-source means durable memory collections are grouped together, while session transcript collections remain a separate group so source diversification still has both inputs.
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -24,6 +24,7 @@ title: "Tests"
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs are full-suite proof: they use fixed shard groups, expand to leaf configs for local parallel execution, and print the expected local shard fanout before starting. The extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
|
||||
- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store.
|
||||
- `pnpm test:env-mutations:report`: non-blocking report of tests and harnesses that mutate `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, `OPENCLAW_WORKSPACE_DIR`, or related OpenClaw env keys directly. Use it to find candidates for migration to the shared test-state helper.
|
||||
- Control UI mocked E2E: use `pnpm test:ui:e2e` for the Vitest + Playwright lane that starts the Vite Control UI and drives a real Chromium page against a mocked Gateway WebSocket. Tests live in `ui/src/**/*.e2e.test.ts`; shared mocks and controls live in `ui/src/test-helpers/control-ui-e2e.ts`. `pnpm test:e2e` includes this lane. In Codex worktrees, prefer `node scripts/run-vitest.mjs run --config test/vitest/vitest.ui-e2e.config.ts --configLoader runner ui/src/ui/e2e/chat-flow.e2e.test.ts` for tiny targeted proof after dependencies are installed, or Testbox/Crabbox for broader GUI proof.
|
||||
- Process E2E helpers: use `test/helpers/openclaw-test-instance.ts` when a Vitest process-level E2E test needs a running Gateway, CLI env, log capture, and cleanup in one place.
|
||||
- TUI PTY tests: use `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` for the fast fake-backend PTY lane. Use `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` or `pnpm tui:pty:test:watch --mode local` for the slower `tui --local` smoke, which mocks only the external model endpoint. Assert stable visible text or fixture calls, not raw ANSI snapshots.
|
||||
@@ -48,7 +49,7 @@ title: "Tests"
|
||||
- `pnpm test:e2e`: Runs the repo E2E aggregate: gateway end-to-end smoke tests plus the Control UI mocked browser E2E lane.
|
||||
- `pnpm test:e2e:gateway`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
|
||||
- `pnpm test:docker:all`: Builds the shared live-test image, packs OpenClaw once as an npm tarball, builds/reuses a bare Node/Git runner image plus a functional image that installs that tarball into `/app`, then runs Docker smoke lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1` through a weighted scheduler. The bare image (`OPENCLAW_DOCKER_E2E_BARE_IMAGE`) is used for installer/update/plugin-dependency lanes; those lanes mount the prebuilt tarball instead of using copied repo sources. The functional image (`OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`) is used for normal built-app functionality lanes. `scripts/package-openclaw-for-docker.mjs` is the single local/CI package packer and validates the tarball plus `dist/postinstall-inventory.json` before Docker consumes it. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. `node scripts/test-docker-all.mjs --plan-json` emits the scheduler-owned CI plan for selected lanes, image kinds, package/live-image needs, state scenarios, and credential checks without building or running Docker. `OPENCLAW_DOCKER_ALL_PARALLELISM=<n>` controls process slots and defaults to 10; `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM=<n>` controls the provider-sensitive tail pool and defaults to 10. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; provider caps default to one heavy lane per provider via `OPENCLAW_DOCKER_ALL_LIVE_CLAUDE_LIMIT=4`, `OPENCLAW_DOCKER_ALL_LIVE_CODEX_LIMIT=4`, and `OPENCLAW_DOCKER_ALL_LIVE_GEMINI_LIMIT=4`. Use `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` for larger hosts. If one lane exceeds the effective weight or resource cap on a low-parallelism host, it can still start from an empty pool and will run alone until it releases capacity. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=<ms>`. The runner preflights Docker by default, cleans stale OpenClaw E2E containers, emits active-lane status every 30 seconds, shares provider CLI tool caches between compatible lanes, retries transient live-provider failures once by default (`OPENCLAW_DOCKER_ALL_LIVE_RETRIES=<n>`), and stores lane timings in `.artifacts/docker-tests/lane-timings.json` for longest-first ordering on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the lane manifest without running Docker, `OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS=<ms>` to tune status output, or `OPENCLAW_DOCKER_ALL_TIMINGS=0` to disable timing reuse. Use `OPENCLAW_DOCKER_ALL_LIVE_MODE=skip` for deterministic/local lanes only or `OPENCLAW_DOCKER_ALL_LIVE_MODE=only` for live-provider lanes only; package aliases are `pnpm test:docker:local:all` and `pnpm test:docker:live:all`. Live-only mode merges main and tail live lanes into one longest-first pool so provider buckets can pack Claude, Codex, and Gemini work together. The runner stops scheduling new pooled lanes after the first failure unless `OPENCLAW_DOCKER_ALL_FAIL_FAST=0` is set, and each lane has a 120-minute fallback timeout overridable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. CLI backend Docker setup commands have their own timeout via `OPENCLAW_LIVE_CLI_BACKEND_SETUP_TIMEOUT_SECONDS` (default 180). Per-lane logs, `summary.json`, `failures.json`, and phase timings are written under `.artifacts/docker-tests/<run-id>/`; use `pnpm test:docker:timings <summary.json>` to inspect slow lanes and `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands.
|
||||
- `pnpm test:docker:all`: Builds the shared live-test image, packs OpenClaw once as an npm tarball, builds/reuses a bare Node/Git runner image plus a functional image that installs that tarball into `/app`, then runs Docker smoke lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1` through a weighted scheduler. The bare image (`OPENCLAW_DOCKER_E2E_BARE_IMAGE`) is used for installer/update/plugin-dependency lanes; those lanes mount the prebuilt tarball instead of using copied repo sources. The functional image (`OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`) is used for normal built-app functionality lanes. `scripts/package-openclaw-for-docker.mjs` is the single local/CI package packer and validates the tarball plus `dist/postinstall-inventory.json` before Docker consumes it. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. `node scripts/test-docker-all.mjs --plan-json` emits the scheduler-owned CI plan for selected lanes, image kinds, package/live-image needs, state scenarios, and credential checks without building or running Docker. `OPENCLAW_DOCKER_ALL_PARALLELISM=<n>` controls process slots and defaults to 10; `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM=<n>` controls the provider-sensitive tail pool and defaults to 10. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=5`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; provider caps default to one heavy lane per provider via `OPENCLAW_DOCKER_ALL_LIVE_CLAUDE_LIMIT=4`, `OPENCLAW_DOCKER_ALL_LIVE_CODEX_LIMIT=4`, and `OPENCLAW_DOCKER_ALL_LIVE_GEMINI_LIMIT=4`. Use `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` for larger hosts. If one lane exceeds the effective weight or resource cap on a low-parallelism host, it can still start from an empty pool and will run alone until it releases capacity. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=<ms>`. The runner preflights Docker by default, cleans stale OpenClaw E2E containers, emits active-lane status every 30 seconds, shares provider CLI tool caches between compatible lanes, retries transient live-provider failures once by default (`OPENCLAW_DOCKER_ALL_LIVE_RETRIES=<n>`), and stores lane timings in `.artifacts/docker-tests/lane-timings.json` for longest-first ordering on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the lane manifest without running Docker, `OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS=<ms>` to tune status output, or `OPENCLAW_DOCKER_ALL_TIMINGS=0` to disable timing reuse. Use `OPENCLAW_DOCKER_ALL_LIVE_MODE=skip` for deterministic/local lanes only or `OPENCLAW_DOCKER_ALL_LIVE_MODE=only` for live-provider lanes only; package aliases are `pnpm test:docker:local:all` and `pnpm test:docker:live:all`. Live-only mode merges main and tail live lanes into one longest-first pool so provider buckets can pack Claude, Codex, and Gemini work together. The runner stops scheduling new pooled lanes after the first failure unless `OPENCLAW_DOCKER_ALL_FAIL_FAST=0` is set, and each lane has a 120-minute fallback timeout overridable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. CLI backend Docker setup commands have their own timeout via `OPENCLAW_LIVE_CLI_BACKEND_SETUP_TIMEOUT_SECONDS` (default 180). Per-lane logs, `summary.json`, `failures.json`, and phase timings are written under `.artifacts/docker-tests/<run-id>/`; use `pnpm test:docker:timings <summary.json>` to inspect slow lanes and `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands.
|
||||
- `pnpm test:docker:browser-cdp-snapshot`: Builds a Chromium-backed source E2E container, starts raw CDP plus an isolated Gateway, runs `browser doctor --deep`, and verifies CDP role snapshots include link URLs, cursor-promoted clickables, iframe refs, and frame metadata.
|
||||
- `pnpm test:docker:skill-install`: Installs the packed OpenClaw tarball in a bare Docker runner, disables `skills.install.allowUploadedArchives`, resolves a current skill slug from live ClawHub search, installs it through `openclaw skills install`, and verifies `SKILL.md`, `.clawhub/origin.json`, `.clawhub/lock.json`, and `skills info --json`.
|
||||
- CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:claude`, `pnpm test:docker:live-cli-backend:claude:resume`, or `pnpm test:docker:live-cli-backend:claude:mcp`. Gemini has matching `:resume` and `:mcp` aliases.
|
||||
|
||||
@@ -150,6 +150,13 @@ inter-session user turns that only have provenance metadata.
|
||||
- Turn validation (merge consecutive user turns to satisfy strict alternation).
|
||||
- Trailing assistant prefill turns are stripped from outgoing Anthropic Messages
|
||||
payloads when thinking is enabled, including Cloudflare AI Gateway routes.
|
||||
- Pre-compaction assistant thinking signatures are stripped before provider
|
||||
replay when a session has been compacted. Thinking signatures are
|
||||
cryptographically bound to the conversation prefix at generation time; after
|
||||
compaction the prefix changes (summarized content is replaced by a compaction
|
||||
summary), so replaying the original signatures causes Anthropic to reject the
|
||||
request with "Invalid signature in thinking block". The thinking text is
|
||||
preserved as an unsigned block and is then handled by the rule below.
|
||||
- Thinking blocks with missing, empty, or blank replay signatures are stripped
|
||||
before provider conversion. If that empties an assistant turn, OpenClaw keeps
|
||||
turn shape with non-empty omitted-reasoning text.
|
||||
@@ -165,6 +172,9 @@ inter-session user turns that only have provenance metadata.
|
||||
repaired on disk before load.
|
||||
- Assistant stream-error turns that contain only blank text blocks are dropped
|
||||
from the in-memory replay copy instead of replaying an invalid blank block.
|
||||
- Pre-compaction assistant thinking signatures are stripped before Converse
|
||||
replay when a session has been compacted, for the same reason as Anthropic
|
||||
above.
|
||||
- Claude thinking blocks with missing, empty, or blank replay signatures are
|
||||
stripped before Converse replay. If that empties an assistant turn, OpenClaw
|
||||
keeps turn shape with non-empty omitted-reasoning text.
|
||||
|
||||
@@ -172,7 +172,7 @@ By default, OpenClaw runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls.
|
||||
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls.
|
||||
- If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat.
|
||||
- By default, heartbeat delivery to DM-style `user:<id>` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.
|
||||
|
||||
@@ -287,6 +287,9 @@ Generic model:
|
||||
- Slack plugin approvals can use Slack's native approval client when the request comes from Slack
|
||||
and Slack plugin approvers resolve; `approvals.plugin` can also route plugin approvals to Slack
|
||||
sessions or targets even when Slack exec approvals are disabled
|
||||
- Google Chat native approval cards handle exec and plugin approvals that originate from Google
|
||||
Chat spaces or threads when stable `users/<id>` approvers resolve from `dm.allowFrom` or
|
||||
`defaultTo`; they do not use reaction events for decisions
|
||||
- WhatsApp and Signal reaction approval delivery are gated by `approvals.exec` and
|
||||
`approvals.plugin`; they do not have `channels.<channel>.execApprovals` blocks
|
||||
|
||||
@@ -306,6 +309,8 @@ FAQ: [Why are there two exec approval configs for chat approvals?](/help/faq-fir
|
||||
- Discord: `channels.discord.execApprovals.*`
|
||||
- Slack: `channels.slack.execApprovals.*`
|
||||
- Telegram: `channels.telegram.execApprovals.*`
|
||||
- Google Chat: configure stable approvers with `channels.googlechat.dm.allowFrom` or
|
||||
`channels.googlechat.defaultTo`; no `execApprovals` block is required
|
||||
- WhatsApp: use `approvals.exec` and `approvals.plugin` to route approval prompts to WhatsApp
|
||||
- Signal: use `approvals.exec` and `approvals.plugin` to route approval prompts to Signal
|
||||
|
||||
@@ -325,6 +330,9 @@ Shared behavior:
|
||||
routing, not Slack exec approvers
|
||||
- Slack native buttons preserve approval id kind, so `plugin:` ids can resolve plugin approvals
|
||||
without a second Slack-local fallback layer
|
||||
- Google Chat native cards preserve the manual `/approve` fallback in message text but card button
|
||||
callbacks carry only opaque action tokens; approval id and decision are recovered from server-side
|
||||
pending state
|
||||
- WhatsApp emoji approvals handle both exec and plugin prompts only when the matching top-level
|
||||
forwarding family is enabled and routes to WhatsApp; target-only WhatsApp forwarding stays on
|
||||
the shared forwarding path unless it matches the same native origin target
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover index plugin behavior.
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover register plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { runtimeRegistry } = vi.hoisted(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover claude agent acp completion plugin behavior.
|
||||
import { ClaudeAcpAgent } from "@agentclientprotocol/claude-agent-acp";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover codex auth bridge plugin behavior.
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover config plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover manifest plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover process lease plugin behavior.
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover process reaper plugin behavior.
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OPENCLAW_ACPX_LEASE_ID_ARG, OPENCLAW_GATEWAY_INSTANCE_ID_ARG } from "./process-lease.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover mcp command line plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type SplitCommandLine = (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover mcp proxy plugin behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover runtime plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// ACPX tests cover service plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Active Memory tests cover config plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
type JsonSchemaObject,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Active Memory tests cover doctor contract api plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Active Memory tests cover index plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Admin Http Rpc tests cover index plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Admin Http Rpc tests cover handler plugin behavior.
|
||||
import { Readable } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleAdminHttpRpcRequest } from "./handler.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Alibaba tests cover video generation provider plugin behavior.
|
||||
import {
|
||||
getProviderHttpMocks,
|
||||
installProviderHttpMockCleanup,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock Mantle tests cover discovery plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock Mantle tests cover index plugin behavior.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bedrockMantlePlugin from "./index.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock Mantle tests cover mantle anthropic plugin behavior.
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover config compat plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover discovery plugin behavior.
|
||||
import type { BedrockClient } from "@aws-sdk/client-bedrock";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover embedding provider plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { testing, hasAwsCredentials } from "./embedding-provider.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover index plugin behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover lazy import plugin behavior.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover memory embedding adapter plugin behavior.
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hasAwsCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover provider policy api plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Amazon Bedrock tests cover stream plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { testing } from "./stream.runtime.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover api plugin behavior.
|
||||
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover index plugin behavior.
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover provider discovery.import guard plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("anthropic-vertex provider discovery entry", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover provider policy api plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover region.adc plugin behavior.
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover region plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl } from "./api.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic Vertex tests cover stream runtime plugin behavior.
|
||||
import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic tests cover cli migration plugin behavior.
|
||||
import type {
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic tests cover cli shared plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic tests cover index plugin behavior.
|
||||
import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic tests cover provider policy api plugin behavior.
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic tests cover provider runtime.contract plugin behavior.
|
||||
import { describeAnthropicProviderRuntimeContract } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
|
||||
describeAnthropicProviderRuntimeContract(() => import("./index.js"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Anthropic tests cover stream wrappers plugin behavior.
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Arcee tests cover index plugin behavior.
|
||||
import {
|
||||
registerSingleProviderPlugin,
|
||||
resolveProviderPluginChoice,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Azure Speech tests cover azure speech plugin behavior.
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Azure Speech tests cover speech provider plugin behavior.
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { azureSpeechTTSMock, listAzureSpeechVoicesMock } = vi.hoisted(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Azure Speech tests cover tts plugin behavior.
|
||||
import { installPinnedHostnameTestHooks } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bonjour tests cover index plugin behavior.
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { afterAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bonjour tests cover manifest plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bonjour tests cover advertiser plugin behavior.
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bonjour tests cover ciao plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = await import("./ciao.js");
|
||||
|
||||
@@ -56,7 +56,11 @@ export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClass
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Alternate export name for unhandled-rejection classification. */
|
||||
/**
|
||||
* Backward-compatible alias for unhandled-rejection classification.
|
||||
*
|
||||
* @deprecated Use classifyCiaoProcessError.
|
||||
*/
|
||||
export const classifyCiaoUnhandledRejection = classifyCiaoProcessError;
|
||||
|
||||
/** Return whether a ciao unhandled rejection is known and ignorable. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Bonjour tests cover errors plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatBonjourError } from "./errors.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Brave tests cover brave web search provider.merge plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Brave tests cover brave web search provider plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Browser tests cover index plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Browser tests cover browser tool.schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BrowserToolSchema } from "./browser-tool.schema.js";
|
||||
import { ACT_MAX_VIEWPORT_DIMENSION } from "./browser/act-policy.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Browser tests cover browser tool plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const browserClientMocks = vi.hoisted(() => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Browser tests cover bridge server.auth plugin behavior.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "./bridge-server.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user