Merge branch 'main' into codex/fix-cron-legacy-migration

This commit is contained in:
Josh Lehman
2026-06-05 10:11:58 -07:00
committed by GitHub
10447 changed files with 34861 additions and 6612 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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")
}
}

View File

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

View File

@@ -1,3 +1,4 @@
// Bundled A2UI runtime resource embedded by OpenClawKit.
var __defProp$1 = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// ACPX tests cover register plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
const { runtimeRegistry } = vi.hoisted(() => ({

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// ACPX tests cover manifest plugin behavior.
import fs from "node:fs";
import { describe, expect, it } from "vitest";

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// ACPX tests cover mcp command line plugin behavior.
import { describe, expect, it } from "vitest";
type SplitCommandLine = (

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Active Memory tests cover config plugin behavior.
import fs from "node:fs";
import {
type JsonSchemaObject,

View File

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

View File

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

View File

@@ -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" };

View File

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

View File

@@ -1,3 +1,4 @@
// Alibaba tests cover video generation provider plugin behavior.
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,

View File

@@ -1,3 +1,4 @@
// Amazon Bedrock Mantle tests cover discovery plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Amazon Bedrock tests cover config compat plugin behavior.
import { describe, expect, it } from "vitest";
import { migrateAmazonBedrockLegacyConfig } from "./config-compat.js";

View File

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

View File

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

View File

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

View File

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

View File

@@ -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());

View File

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

View File

@@ -1,3 +1,4 @@
// Amazon Bedrock tests cover stream plugin behavior.
import { describe, expect, it } from "vitest";
import { testing } from "./stream.runtime.js";

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Anthropic Vertex tests cover region plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl } from "./api.js";

View File

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

View File

@@ -1,3 +1,4 @@
// Anthropic tests cover cli migration plugin behavior.
import type {
ProviderAuthContext,
ProviderAuthMethodNonInteractiveContext,

View File

@@ -1,3 +1,4 @@
// Anthropic tests cover cli shared plugin behavior.
import { describe, expect, it } from "vitest";
import { buildAnthropicCliBackend } from "./cli-backend.js";
import {

View File

@@ -1,3 +1,4 @@
// Anthropic tests cover index plugin behavior.
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,

View File

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

View File

@@ -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"));

View File

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

View File

@@ -1,3 +1,4 @@
// Arcee tests cover index plugin behavior.
import {
registerSingleProviderPlugin,
resolveProviderPluginChoice,

View File

@@ -1,3 +1,4 @@
// Azure Speech tests cover azure speech plugin behavior.
import {
registerProviderPlugin,
requireRegisteredProvider,

View File

@@ -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(() => ({

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Bonjour tests cover manifest plugin behavior.
import fs from "node:fs";
import { describe, expect, it } from "vitest";

View File

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

View File

@@ -1,3 +1,4 @@
// Bonjour tests cover ciao plugin behavior.
import { describe, expect, it } from "vitest";
const { classifyCiaoUnhandledRejection, ignoreCiaoUnhandledRejection } = await import("./ciao.js");

View File

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

View File

@@ -1,3 +1,4 @@
// Bonjour tests cover errors plugin behavior.
import { describe, expect, it } from "vitest";
import { formatBonjourError } from "./errors.js";

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
// Browser tests cover browser tool plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const browserClientMocks = vi.hoisted(() => ({

View File

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