diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f87c816488..d892d3f30df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,26 +215,37 @@ jobs: - runtime: bun task: test command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts + - runtime: node + task: compat-node22 + node_version: "22.x" + cache_key_suffix: "node22" + command: | + pnpm build + pnpm test + node scripts/stage-bundled-plugin-runtime-deps.mjs + node --import tsx scripts/release-check.ts steps: - - name: Skip bun lane on pull requests - if: github.event_name == 'pull_request' && matrix.runtime == 'bun' - run: echo "Skipping Bun compatibility lane on pull requests." + - name: Skip compatibility lanes on pull requests + if: github.event_name == 'pull_request' && (matrix.runtime == 'bun' || matrix.task == 'compat-node22') + run: echo "Skipping push-only lane on pull requests." - name: Checkout - if: github.event_name != 'pull_request' || matrix.runtime != 'bun' + if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22') uses: actions/checkout@v6 with: submodules: false - name: Setup Node environment - if: matrix.runtime != 'bun' || github.event_name != 'pull_request' + if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22') uses: ./.github/actions/setup-node-env with: + node-version: "${{ matrix.node_version || '24.x' }}" + cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}" install-bun: "${{ matrix.runtime == 'bun' }}" use-sticky-disk: "false" - name: Configure Node test resources - if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' + if: (github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')) && matrix.runtime == 'node' && (matrix.task == 'test' || matrix.task == 'compat-node22') env: SHARD_COUNT: ${{ matrix.shard_count || '' }} SHARD_INDEX: ${{ matrix.shard_index || '' }} @@ -249,11 +260,11 @@ jobs: fi - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - if: matrix.runtime != 'bun' || github.event_name != 'pull_request' + if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22') run: ${{ matrix.command }} extension-fast: - name: "extension-fast (${{ matrix.extension }})" + name: "extension-fast" needs: [docs-scope, changed-scope, changed-extensions] if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 @@ -464,45 +475,9 @@ jobs: - name: Check docs run: pnpm check:docs - compat-node22: - name: "compat-node22" - needs: [docs-scope, changed-scope] - if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' - runs-on: blacksmith-16vcpu-ubuntu-2404 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - submodules: false - - - name: Setup Node 22 compatibility environment - uses: ./.github/actions/setup-node-env - with: - node-version: "22.x" - cache-key-suffix: "node22" - install-bun: "false" - use-sticky-disk: "false" - - - name: Configure Node 22 test resources - run: | - # Keep the compatibility lane aligned with the default Node test lane. - echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" - echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" - - - name: Build under Node 22 - run: pnpm build - - - name: Run tests under Node 22 - run: pnpm test - - - name: Verify npm pack under Node 22 - run: | - node scripts/stage-bundled-plugin-runtime-deps.mjs - node --import tsx scripts/release-check.ts - skills-python: needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true' + if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_skills_python == 'true') runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout @@ -972,10 +947,14 @@ jobs: fail-fast: false matrix: include: - - task: test - command: ./gradlew --no-daemon :app:testDebugUnitTest - - task: build - command: ./gradlew --no-daemon :app:assembleDebug + - task: test-play + command: ./gradlew --no-daemon :app:testPlayDebugUnitTest + - task: test-third-party + command: ./gradlew --no-daemon :app:testThirdPartyDebugUnitTest + - task: build-play + command: ./gradlew --no-daemon :app:assemblePlayDebug + - task: build-third-party + command: ./gradlew --no-daemon :app:assembleThirdPartyDebug steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 79c041ef727..e3f9db202b7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -116,7 +116,7 @@ jobs: - name: Build Android for CodeQL if: matrix.language == 'java-kotlin' working-directory: apps/android - run: ./gradlew --no-daemon :app:assembleDebug + run: ./gradlew --no-daemon :app:assemblePlayDebug - name: Build Swift for CodeQL if: matrix.language == 'swift' diff --git a/apps/android/README.md b/apps/android/README.md index 008941ecda7..e8694dbbdb8 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -27,14 +27,34 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u ```bash cd apps/android -./gradlew :app:assembleDebug -./gradlew :app:installDebug -./gradlew :app:testDebugUnitTest +./gradlew :app:assemblePlayDebug +./gradlew :app:installPlayDebug +./gradlew :app:testPlayDebugUnitTest cd ../.. bun run android:bundle:release ``` -`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`. +Third-party debug flavor: + +```bash +cd apps/android +./gradlew :app:assembleThirdPartyDebug +./gradlew :app:installThirdPartyDebug +./gradlew :app:testThirdPartyDebugUnitTest +``` + +`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds two signed release bundles: + +- Play build: `apps/android/build/release-bundles/openclaw--play-release.aab` +- Third-party build: `apps/android/build/release-bundles/openclaw--third-party-release.aab` + +Flavor-specific direct Gradle tasks: + +```bash +cd apps/android +./gradlew :app:bundlePlayRelease +./gradlew :app:bundleThirdPartyRelease +``` ## Kotlin Lint + Format @@ -194,6 +214,9 @@ Current OpenClaw Android implication: - APK / sideload build can keep SMS and Call Log features. - Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case. +- The repo now ships this split as Android product flavors: + - `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities. + - `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality. Policy links: diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 46afccbc3bf..73882f69439 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -65,14 +65,29 @@ android { applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 2026031400 - versionName = "2026.3.14" + versionCode = 2026032000 + versionName = "2026.3.20" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") } } + flavorDimensions += "store" + + productFlavors { + create("play") { + dimension = "store" + buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false") + buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false") + } + create("thirdParty") { + dimension = "store" + buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true") + buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true") + } + } + buildTypes { release { if (hasAndroidReleaseSigning) { @@ -140,8 +155,13 @@ androidComponents { .forEach { output -> val versionName = output.versionName.orNull ?: "0" val buildType = variant.buildType - - val outputFileName = "openclaw-$versionName-$buildType.apk" + val flavorName = variant.flavorName?.takeIf { it.isNotBlank() } + val outputFileName = + if (flavorName == null) { + "openclaw-$versionName-$buildType.apk" + } else { + "openclaw-$versionName-$flavorName-$buildType.apk" + } output.outputFileName = outputFileName } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 6dd1b83d3bb..0149aa9d09b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -89,6 +89,8 @@ class NodeRuntime( private val deviceHandler: DeviceHandler = DeviceHandler( appContext = appContext, + smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS, + callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG, ) private val notificationsHandler: NotificationsHandler = NotificationsHandler( @@ -137,8 +139,9 @@ class NodeRuntime( voiceWakeMode = { VoiceWakeMode.Off }, motionActivityAvailable = { motionHandler.isActivityAvailable() }, motionPedometerAvailable = { motionHandler.isPedometerAvailable() }, - sendSmsAvailable = { sms.canSendSms() }, - readSmsAvailable = { sms.canReadSms() }, + sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() }, + readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() }, + callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }, hasRecordAudioPermission = { hasRecordAudioPermission() }, manualTls = { manualTls.value }, ) @@ -161,8 +164,9 @@ class NodeRuntime( isForeground = { _isForeground.value }, cameraEnabled = { cameraEnabled.value }, locationEnabled = { locationMode.value != LocationMode.Off }, - sendSmsAvailable = { sms.canSendSms() }, - readSmsAvailable = { sms.canReadSms() }, + sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() }, + readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() }, + callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }, debugBuild = { BuildConfig.DEBUG }, refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() }, onCanvasA2uiPush = { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt index ce9c9d77bfc..d58049c6059 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/ConnectionManager.kt @@ -19,6 +19,7 @@ class ConnectionManager( private val motionPedometerAvailable: () -> Boolean, private val sendSmsAvailable: () -> Boolean, private val readSmsAvailable: () -> Boolean, + private val callLogAvailable: () -> Boolean, private val hasRecordAudioPermission: () -> Boolean, private val manualTls: () -> Boolean, ) { @@ -81,6 +82,7 @@ class ConnectionManager( locationEnabled = locationMode() != LocationMode.Off, sendSmsAvailable = sendSmsAvailable(), readSmsAvailable = readSmsAvailable(), + callLogAvailable = callLogAvailable(), voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(), motionActivityAvailable = motionActivityAvailable(), motionPedometerAvailable = motionPedometerAvailable(), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt index b888e3edaea..ad80d75f257 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/DeviceHandler.kt @@ -25,6 +25,8 @@ import kotlinx.serialization.json.put class DeviceHandler( private val appContext: Context, + private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS, + private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG, ) { private data class BatterySnapshot( val status: Int, @@ -173,8 +175,8 @@ class DeviceHandler( put( "sms", permissionStateJson( - granted = hasPermission(Manifest.permission.SEND_SMS) && canSendSms, - promptableWhenDenied = canSendSms, + granted = smsEnabled && hasPermission(Manifest.permission.SEND_SMS) && canSendSms, + promptableWhenDenied = smsEnabled && canSendSms, ), ) put( @@ -215,8 +217,8 @@ class DeviceHandler( put( "callLog", permissionStateJson( - granted = hasPermission(Manifest.permission.READ_CALL_LOG), - promptableWhenDenied = true, + granted = callLogEnabled && hasPermission(Manifest.permission.READ_CALL_LOG), + promptableWhenDenied = callLogEnabled, ), ) put( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt index 3e903098196..6c755830a24 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeCommandRegistry.kt @@ -20,6 +20,7 @@ data class NodeRuntimeFlags( val locationEnabled: Boolean, val sendSmsAvailable: Boolean, val readSmsAvailable: Boolean, + val callLogAvailable: Boolean, val voiceWakeEnabled: Boolean, val motionActivityAvailable: Boolean, val motionPedometerAvailable: Boolean, @@ -32,6 +33,7 @@ enum class InvokeCommandAvailability { LocationEnabled, SendSmsAvailable, ReadSmsAvailable, + CallLogAvailable, MotionActivityAvailable, MotionPedometerAvailable, DebugBuild, @@ -42,6 +44,7 @@ enum class NodeCapabilityAvailability { CameraEnabled, LocationEnabled, SmsAvailable, + CallLogAvailable, VoiceWakeEnabled, MotionAvailable, } @@ -87,7 +90,10 @@ object InvokeCommandRegistry { name = OpenClawCapability.Motion.rawValue, availability = NodeCapabilityAvailability.MotionAvailable, ), - NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue), + NodeCapabilitySpec( + name = OpenClawCapability.CallLog.rawValue, + availability = NodeCapabilityAvailability.CallLogAvailable, + ), ) val all: List = @@ -197,6 +203,7 @@ object InvokeCommandRegistry { ), InvokeCommandSpec( name = OpenClawCallLogCommand.Search.rawValue, + availability = InvokeCommandAvailability.CallLogAvailable, ), InvokeCommandSpec( name = "debug.logs", @@ -220,6 +227,7 @@ object InvokeCommandRegistry { NodeCapabilityAvailability.CameraEnabled -> flags.cameraEnabled NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable + NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable } @@ -236,6 +244,7 @@ object InvokeCommandRegistry { InvokeCommandAvailability.LocationEnabled -> flags.locationEnabled InvokeCommandAvailability.SendSmsAvailable -> flags.sendSmsAvailable InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable + InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable InvokeCommandAvailability.DebugBuild -> flags.debugBuild diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt index 2ed0773bc43..17df029a5c6 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/InvokeDispatcher.kt @@ -34,6 +34,7 @@ class InvokeDispatcher( private val locationEnabled: () -> Boolean, private val sendSmsAvailable: () -> Boolean, private val readSmsAvailable: () -> Boolean, + private val callLogAvailable: () -> Boolean, private val debugBuild: () -> Boolean, private val refreshNodeCanvasCapability: suspend () -> Boolean, private val onCanvasA2uiPush: () -> Unit, @@ -276,6 +277,15 @@ class InvokeDispatcher( message = "SMS_UNAVAILABLE: SMS not available on this device", ) } + InvokeCommandAvailability.CallLogAvailable -> + if (callLogAvailable()) { + null + } else { + GatewaySession.InvokeResult.error( + code = "CALL_LOG_UNAVAILABLE", + message = "CALL_LOG_UNAVAILABLE: call log not available on this build", + ) + } InvokeCommandAvailability.DebugBuild -> if (debugBuild()) { null diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 1f4774a537d..28a28e281c1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -93,6 +93,7 @@ import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import ai.openclaw.app.BuildConfig import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.node.DeviceNotificationListenerService @@ -238,8 +239,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { val smsAvailable = remember(context) { - context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + BuildConfig.OPENCLAW_ENABLE_SMS && + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } + val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG } val motionAvailable = remember(context) { hasMotionCapabilities(context) @@ -297,7 +300,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { } var enableCallLog by rememberSaveable { - mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)) + mutableStateOf(callLogAvailable && isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)) } var pendingPermissionToggle by remember { mutableStateOf(null) } @@ -315,7 +318,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { PermissionToggle.Calendar -> enableCalendar = enabled PermissionToggle.Motion -> enableMotion = enabled && motionAvailable PermissionToggle.Sms -> enableSms = enabled && smsAvailable - PermissionToggle.CallLog -> enableCallLog = enabled + PermissionToggle.CallLog -> enableCallLog = enabled && callLogAvailable } } @@ -345,7 +348,8 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { !smsAvailable || (isPermissionGranted(context, Manifest.permission.SEND_SMS) && isPermissionGranted(context, Manifest.permission.READ_SMS)) - PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG) + PermissionToggle.CallLog -> + !callLogAvailable || isPermissionGranted(context, Manifest.permission.READ_CALL_LOG) } fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) { @@ -369,6 +373,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { enableSms, enableCallLog, smsAvailable, + callLogAvailable, motionAvailable, ) { val enabled = mutableListOf() @@ -383,7 +388,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { if (enableCalendar) enabled += "Calendar" if (enableMotion && motionAvailable) enabled += "Motion" if (smsAvailable && enableSms) enabled += "SMS" - if (enableCallLog) enabled += "Call Log" + if (callLogAvailable && enableCallLog) enabled += "Call Log" if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") } @@ -612,6 +617,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { motionPermissionRequired = motionPermissionRequired, enableSms = enableSms, smsAvailable = smsAvailable, + callLogAvailable = callLogAvailable, enableCallLog = enableCallLog, context = context, onDiscoveryChange = { checked -> @@ -711,11 +717,15 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { } }, onCallLogChange = { checked -> - requestPermissionToggle( - PermissionToggle.CallLog, - checked, - listOf(Manifest.permission.READ_CALL_LOG), - ) + if (!callLogAvailable) { + setPermissionToggleEnabled(PermissionToggle.CallLog, false) + } else { + requestPermissionToggle( + PermissionToggle.CallLog, + checked, + listOf(Manifest.permission.READ_CALL_LOG), + ) + } }, ) OnboardingStep.FinalCheck -> @@ -1307,6 +1317,7 @@ private fun PermissionsStep( motionPermissionRequired: Boolean, enableSms: Boolean, smsAvailable: Boolean, + callLogAvailable: Boolean, enableCallLog: Boolean, context: Context, onDiscoveryChange: (Boolean) -> Unit, @@ -1453,14 +1464,16 @@ private fun PermissionsStep( onCheckedChange = onSmsChange, ) } - InlineDivider() - PermissionToggleRow( - title = "Call Log", - subtitle = "callLog.search", - checked = enableCallLog, - granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG), - onCheckedChange = onCallLogChange, - ) + if (callLogAvailable) { + InlineDivider() + PermissionToggleRow( + title = "Call Log", + subtitle = "callLog.search", + checked = enableCallLog, + granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG), + onCheckedChange = onCallLogChange, + ) + } Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index f78e4535bcb..e7ad138dc21 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -149,8 +149,10 @@ fun SettingsSheet(viewModel: MainViewModel) { val smsPermissionAvailable = remember { - context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true + BuildConfig.OPENCLAW_ENABLE_SMS && + context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true } + val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG } val photosPermission = if (Build.VERSION.SDK_INT >= 33) { Manifest.permission.READ_MEDIA_IMAGES @@ -622,31 +624,33 @@ fun SettingsSheet(viewModel: MainViewModel) { } }, ) - HorizontalDivider(color = mobileBorder) - ListItem( - modifier = Modifier.fillMaxWidth(), - colors = listItemColors, - headlineContent = { Text("Call Log", style = mobileHeadline) }, - supportingContent = { Text("Search recent call history.", style = mobileCallout) }, - trailingContent = { - Button( - onClick = { - if (callLogPermissionGranted) { - openAppSettings(context) - } else { - callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG) - } - }, - colors = settingsPrimaryButtonColors(), - shape = RoundedCornerShape(14.dp), - ) { - Text( - if (callLogPermissionGranted) "Manage" else "Grant", - style = mobileCallout.copy(fontWeight = FontWeight.Bold), - ) - } - }, - ) + if (callLogPermissionAvailable) { + HorizontalDivider(color = mobileBorder) + ListItem( + modifier = Modifier.fillMaxWidth(), + colors = listItemColors, + headlineContent = { Text("Call Log", style = mobileHeadline) }, + supportingContent = { Text("Search recent call history.", style = mobileCallout) }, + trailingContent = { + Button( + onClick = { + if (callLogPermissionGranted) { + openAppSettings(context) + } else { + callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG) + } + }, + colors = settingsPrimaryButtonColors(), + shape = RoundedCornerShape(14.dp), + ) { + Text( + if (callLogPermissionGranted) "Manage" else "Grant", + style = mobileCallout.copy(fontWeight = FontWeight.Bold), + ) + } + }, + ) + } if (motionAvailable) { HorizontalDivider(color = mobileBorder) ListItem( diff --git a/apps/android/app/src/play/AndroidManifest.xml b/apps/android/app/src/play/AndroidManifest.xml new file mode 100644 index 00000000000..8793dce6d39 --- /dev/null +++ b/apps/android/app/src/play/AndroidManifest.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt index 29decd2f76d..08fc3f26eab 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/InvokeCommandRegistryTest.kt @@ -26,7 +26,6 @@ class InvokeCommandRegistryTest { OpenClawCapability.Photos.rawValue, OpenClawCapability.Contacts.rawValue, OpenClawCapability.Calendar.rawValue, - OpenClawCapability.CallLog.rawValue, ) private val optionalCapabilities = @@ -34,6 +33,7 @@ class InvokeCommandRegistryTest { OpenClawCapability.Camera.rawValue, OpenClawCapability.Location.rawValue, OpenClawCapability.Sms.rawValue, + OpenClawCapability.CallLog.rawValue, OpenClawCapability.VoiceWake.rawValue, OpenClawCapability.Motion.rawValue, ) @@ -52,7 +52,6 @@ class InvokeCommandRegistryTest { OpenClawContactsCommand.Add.rawValue, OpenClawCalendarCommand.Events.rawValue, OpenClawCalendarCommand.Add.rawValue, - OpenClawCallLogCommand.Search.rawValue, ) private val optionalCommands = @@ -65,6 +64,7 @@ class InvokeCommandRegistryTest { OpenClawMotionCommand.Pedometer.rawValue, OpenClawSmsCommand.Send.rawValue, OpenClawSmsCommand.Search.rawValue, + OpenClawCallLogCommand.Search.rawValue, ) private val debugCommands = setOf("debug.logs", "debug.ed25519") @@ -86,6 +86,7 @@ class InvokeCommandRegistryTest { locationEnabled = true, sendSmsAvailable = true, readSmsAvailable = true, + callLogAvailable = true, voiceWakeEnabled = true, motionActivityAvailable = true, motionPedometerAvailable = true, @@ -112,6 +113,7 @@ class InvokeCommandRegistryTest { locationEnabled = true, sendSmsAvailable = true, readSmsAvailable = true, + callLogAvailable = true, motionActivityAvailable = true, motionPedometerAvailable = true, debugBuild = true, @@ -130,6 +132,7 @@ class InvokeCommandRegistryTest { locationEnabled = false, sendSmsAvailable = false, readSmsAvailable = false, + callLogAvailable = false, voiceWakeEnabled = false, motionActivityAvailable = true, motionPedometerAvailable = false, @@ -173,11 +176,26 @@ class InvokeCommandRegistryTest { assertTrue(sendOnlyCapabilities.contains(OpenClawCapability.Sms.rawValue)) } + @Test + fun advertisedCommands_excludesCallLogWhenUnavailable() { + val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags(callLogAvailable = false)) + + assertFalse(commands.contains(OpenClawCallLogCommand.Search.rawValue)) + } + + @Test + fun advertisedCapabilities_excludesCallLogWhenUnavailable() { + val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags(callLogAvailable = false)) + + assertFalse(capabilities.contains(OpenClawCapability.CallLog.rawValue)) + } + private fun defaultFlags( cameraEnabled: Boolean = false, locationEnabled: Boolean = false, sendSmsAvailable: Boolean = false, readSmsAvailable: Boolean = false, + callLogAvailable: Boolean = false, voiceWakeEnabled: Boolean = false, motionActivityAvailable: Boolean = false, motionPedometerAvailable: Boolean = false, @@ -188,6 +206,7 @@ class InvokeCommandRegistryTest { locationEnabled = locationEnabled, sendSmsAvailable = sendSmsAvailable, readSmsAvailable = readSmsAvailable, + callLogAvailable = callLogAvailable, voiceWakeEnabled = voiceWakeEnabled, motionActivityAvailable = motionActivityAvailable, motionPedometerAvailable = motionPedometerAvailable, diff --git a/apps/android/scripts/build-release-aab.ts b/apps/android/scripts/build-release-aab.ts index 30e4bb0390b..625b825e620 100644 --- a/apps/android/scripts/build-release-aab.ts +++ b/apps/android/scripts/build-release-aab.ts @@ -7,7 +7,28 @@ import { fileURLToPath } from "node:url"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const androidDir = join(scriptDir, ".."); const buildGradlePath = join(androidDir, "app", "build.gradle.kts"); -const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab"); +const releaseOutputDir = join(androidDir, "build", "release-bundles"); + +const releaseVariants = [ + { + flavorName: "play", + gradleTask: ":app:bundlePlayRelease", + bundlePath: join(androidDir, "app", "build", "outputs", "bundle", "playRelease", "app-play-release.aab"), + }, + { + flavorName: "third-party", + gradleTask: ":app:bundleThirdPartyRelease", + bundlePath: join( + androidDir, + "app", + "build", + "outputs", + "bundle", + "thirdPartyRelease", + "app-thirdParty-release.aab", + ), + }, +] as const; type VersionState = { versionName: string; @@ -88,6 +109,15 @@ async function verifyBundleSignature(path: string): Promise { await $`jarsigner -verify ${path}`.quiet(); } +async function copyBundle(sourcePath: string, destinationPath: string): Promise { + const sourceFile = Bun.file(sourcePath); + if (!(await sourceFile.exists())) { + throw new Error(`Signed bundle missing at ${sourcePath}`); + } + + await Bun.write(destinationPath, sourceFile); +} + async function main() { const buildGradleFile = Bun.file(buildGradlePath); const originalText = await buildGradleFile.text(); @@ -102,24 +132,28 @@ async function main() { console.log(`Android versionCode -> ${nextVersion.versionCode}`); await Bun.write(buildGradlePath, updatedText); + await $`mkdir -p ${releaseOutputDir}`; try { - await $`./gradlew :app:bundleRelease`.cwd(androidDir); + await $`./gradlew ${releaseVariants[0].gradleTask} ${releaseVariants[1].gradleTask}`.cwd(androidDir); } catch (error) { await Bun.write(buildGradlePath, originalText); throw error; } - const bundleFile = Bun.file(bundlePath); - if (!(await bundleFile.exists())) { - throw new Error(`Signed bundle missing at ${bundlePath}`); + for (const variant of releaseVariants) { + const outputPath = join( + releaseOutputDir, + `openclaw-${nextVersion.versionName}-${variant.flavorName}-release.aab`, + ); + + await copyBundle(variant.bundlePath, outputPath); + await verifyBundleSignature(outputPath); + const hash = await sha256Hex(outputPath); + + console.log(`Signed AAB (${variant.flavorName}): ${outputPath}`); + console.log(`SHA-256 (${variant.flavorName}): ${hash}`); } - - await verifyBundleSignature(bundlePath); - const hash = await sha256Hex(bundlePath); - - console.log(`Signed AAB: ${bundlePath}`); - console.log(`SHA-256: ${hash}`); } await main(); diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 136d5cd87b1..f4715f11ea3 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -53873,6 +53873,169 @@ "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", "hasChildren": false }, + { + "path": "plugins.entries.tavily", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/tavily-plugin", + "help": "OpenClaw Tavily plugin (plugin: tavily)", + "hasChildren": true + }, + { + "path": "plugins.entries.tavily.config", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "@openclaw/tavily-plugin Config", + "help": "Plugin-defined config payload for tavily.", + "hasChildren": true + }, + { + "path": "plugins.entries.tavily.config.webSearch", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": true + }, + { + "path": "plugins.entries.tavily.config.webSearch.apiKey", + "kind": "plugin", + "type": [ + "object", + "string" + ], + "required": false, + "deprecated": false, + "sensitive": true, + "tags": [ + "auth", + "security" + ], + "label": "Tavily API Key", + "help": "Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).", + "hasChildren": false + }, + { + "path": "plugins.entries.tavily.config.webSearch.baseUrl", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Tavily Base URL", + "help": "Tavily API base URL override.", + "hasChildren": false + }, + { + "path": "plugins.entries.tavily.enabled", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Enable @openclaw/tavily-plugin", + "hasChildren": false + }, + { + "path": "plugins.entries.tavily.hooks", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Hook Policy", + "help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.", + "hasChildren": true + }, + { + "path": "plugins.entries.tavily.hooks.allowPromptInjection", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Prompt Injection Hooks", + "help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.", + "hasChildren": false + }, + { + "path": "plugins.entries.tavily.subagent", + "kind": "plugin", + "type": "object", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "advanced" + ], + "label": "Plugin Subagent Policy", + "help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.", + "hasChildren": true + }, + { + "path": "plugins.entries.tavily.subagent.allowedModels", + "kind": "plugin", + "type": "array", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Plugin Subagent Allowed Models", + "help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.", + "hasChildren": true + }, + { + "path": "plugins.entries.tavily.subagent.allowedModels.*", + "kind": "plugin", + "type": "string", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [], + "hasChildren": false + }, + { + "path": "plugins.entries.tavily.subagent.allowModelOverride", + "kind": "plugin", + "type": "boolean", + "required": false, + "deprecated": false, + "sensitive": false, + "tags": [ + "access" + ], + "label": "Allow Plugin Subagent Model Override", + "help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.", + "hasChildren": false + }, { "path": "plugins.entries.telegram", "kind": "plugin", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 39b0e395a75..819422ac9aa 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -1,4 +1,4 @@ -{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5537} +{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5549} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} @@ -4661,6 +4661,18 @@ {"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} {"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"plugins.entries.talk-voice.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tavily","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin","help":"OpenClaw Tavily plugin (plugin: tavily)","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tavily.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/tavily-plugin Config","help":"Plugin-defined config payload for tavily.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tavily.config.webSearch","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} +{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.apiKey","kind":"plugin","type":["object","string"],"required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Tavily API Key","help":"Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tavily.config.webSearch.baseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Tavily Base URL","help":"Tavily API base URL override.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tavily.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/tavily-plugin","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tavily.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tavily.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false} +{"recordType":"path","path":"plugins.entries.tavily.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true} +{"recordType":"path","path":"plugins.entries.tavily.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} +{"recordType":"path","path":"plugins.entries.tavily.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram","help":"OpenClaw Telegram channel plugin (plugin: telegram)","hasChildren":true} {"recordType":"path","path":"plugins.entries.telegram.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/telegram Config","help":"Plugin-defined config payload for telegram.","hasChildren":false} {"recordType":"path","path":"plugins.entries.telegram.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/telegram","hasChildren":false} diff --git a/docs/automation/poll.md b/docs/automation/poll.md index acf03aa2903..de666c7acba 100644 --- a/docs/automation/poll.md +++ b/docs/automation/poll.md @@ -13,7 +13,7 @@ title: "Polls" - Telegram - WhatsApp (web channel) - Discord -- MS Teams (Adaptive Cards) +- Microsoft Teams (Adaptive Cards) ## CLI @@ -37,7 +37,7 @@ openclaw message poll --channel discord --target channel:123456789 \ openclaw message poll --channel discord --target channel:123456789 \ --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 -# MS Teams +# Microsoft Teams openclaw message poll --channel msteams --target conversation:19:abc@thread.tacv2 \ --poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi" ``` @@ -71,7 +71,7 @@ Params: - Telegram: 2-10 options. Supports forum topics via `threadId` or `:topic:` targets. Uses `durationSeconds` instead of `durationHours`, limited to 5-600 seconds. Supports anonymous and public polls. - WhatsApp: 2-12 options, `maxSelections` must be within option count, ignores `durationHours`. - Discord: 2-10 options, `durationHours` clamped to 1-768 hours (default 24). `maxSelections > 1` enables multi-select; Discord does not support a strict selection count. -- MS Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored. +- Microsoft Teams: Adaptive Card polls (OpenClaw-managed). No native poll API; `durationHours` is ignored. ## Agent tool (Message) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 38676a8fdbe..1d9bd550414 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -85,7 +85,7 @@ Payload: - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. - `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`. -- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for MS Teams). Defaults to the last recipient in the main session. +- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session. - `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. - `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`). - `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds. diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 8895cdd18f9..50c4d70164f 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -290,7 +290,7 @@ Example (Telegram): Notes: - Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins). -- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`). +- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, Microsoft Teams `teams.*.channels.*`). ## Group allowlists diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 49a9f04347e..d5e7e1bbc66 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -1,7 +1,7 @@ --- summary: "Microsoft Teams bot support status, capabilities, and configuration" read_when: - - Working on MS Teams channel features + - Working on Microsoft Teams channel features title: "Microsoft Teams" --- @@ -17,9 +17,9 @@ Status: text + DM attachments are supported; channel/group file sending requires Microsoft Teams ships as a plugin and is not bundled with the core install. -**Breaking change (2026.1.15):** MS Teams moved out of core. If you use it, you must install the plugin. +**Breaking change (2026.1.15):** Microsoft Teams moved out of core. If you use it, you must install the plugin. -Explainable: keeps core installs lighter and lets MS Teams dependencies update independently. +Explainable: keeps core installs lighter and lets Microsoft Teams dependencies update independently. Install via CLI (npm registry): diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 96b9ef33f8c..cf8b2367a7f 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -83,7 +83,7 @@ Notes: - `--channel` is optional; omit it to list every channel (including extensions). - `--target` accepts `channel:` or a raw numeric channel id and only applies to Discord. -- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`. +- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; Microsoft Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`. ## Resolve names to IDs diff --git a/docs/cli/index.md b/docs/cli/index.md index f1555b4ea26..adca030ce98 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -424,7 +424,7 @@ Options: ### `channels` -Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams). +Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Microsoft Teams). Subcommands: diff --git a/docs/cli/message.md b/docs/cli/message.md index 665d0e74bd2..784fa654dba 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -9,7 +9,7 @@ title: "message" # `openclaw message` Single outbound command for sending messages and channel actions -(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams). +(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams). ## Usage @@ -33,7 +33,7 @@ Target formats (`--target`): - Mattermost (plugin): `channel:`, `user:`, or `@username` (bare ids are treated as channels) - Signal: `+E.164`, `group:`, `signal:+E.164`, `signal:group:`, or `username:`/`u:` - iMessage: handle, `chat_id:`, `chat_guid:`, or `chat_identifier:` -- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:` or `user:` +- Microsoft Teams: conversation id (`19:...@thread.tacv2`) or `conversation:` or `user:` Name lookup: @@ -65,7 +65,7 @@ Name lookup: ### Core - `send` - - Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams + - Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/Microsoft Teams - Required: `--target`, plus `--message` or `--media` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) @@ -75,7 +75,7 @@ Name lookup: - WhatsApp only: `--gif-playback` - `poll` - - Channels: WhatsApp/Telegram/Discord/Matrix/MS Teams + - Channels: WhatsApp/Telegram/Discord/Matrix/Microsoft Teams - Required: `--target`, `--poll-question`, `--poll-option` (repeat) - Optional: `--poll-multi` - Discord only: `--poll-duration-hours`, `--silent`, `--message` diff --git a/docs/cli/security.md b/docs/cli/security.md index 28b65f3629b..3baac2e38f3 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -37,7 +37,7 @@ It also warns when sandbox browser uses Docker `bridge` network without `sandbox It also flags dangerous sandbox Docker network modes (including `host` and `container:*` namespace joins). It also warns when existing sandbox browser Docker containers have missing/stale hash labels (for example pre-migration containers missing `openclaw.browserConfigEpoch`) and recommends `openclaw sandbox recreate --browser --all`. It also warns when npm-based plugin/hook install records are unpinned, missing integrity metadata, or drift from currently installed package versions. -It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, MS Teams, Mattermost, IRC scopes where applicable). +It warns when channel allowlists rely on mutable names/emails/tags instead of stable IDs (Discord, Slack, Google Chat, Microsoft Teams, Mattermost, IRC scopes where applicable). It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable without a shared secret (`/tools/invoke` plus any enabled `/v1/*` endpoint). Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). diff --git a/docs/concepts/features.md b/docs/concepts/features.md index 56c486ea6f5..b532105952d 100644 --- a/docs/concepts/features.md +++ b/docs/concepts/features.md @@ -35,7 +35,7 @@ title: "Features" **Channels:** - WhatsApp, Telegram, Discord, iMessage (built-in) -- Mattermost, Matrix, MS Teams, Nostr, and more (plugins) +- Mattermost, Matrix, Microsoft Teams, Nostr, and more (plugins) - Group chat support with mention-based activation - DM safety with allowlists and pairing diff --git a/docs/concepts/markdown-formatting.md b/docs/concepts/markdown-formatting.md index 5062e55912f..2aa1fc198b8 100644 --- a/docs/concepts/markdown-formatting.md +++ b/docs/concepts/markdown-formatting.md @@ -57,7 +57,7 @@ IR (schematic): ## Where it is used - Slack, Telegram, and Signal outbound adapters render from the IR. -- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or +- Other channels (WhatsApp, iMessage, Microsoft Teams, Discord) still use plain text or their own formatting rules, with Markdown table conversion applied before chunking when enabled. diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index 8fda608f79f..e412f5b9d91 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -494,7 +494,7 @@ If more than one person can DM your bot (multiple entries in `allowFrom`, pairin } ``` -For Discord/Slack/Google Chat/MS Teams/Mattermost/IRC, sender authorization is ID-first by default. +For Discord/Slack/Google Chat/Microsoft Teams/Mattermost/IRC, sender authorization is ID-first by default. Only enable direct mutable name/email/nick matching with each channel's `dangerouslyAllowNameMatching: true` if you explicitly accept that risk. ### OAuth with API key failover diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 42977c2b6f1..80972376dc3 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -85,7 +85,7 @@ When validation fails: - [iMessage](/channels/imessage) — `channels.imessage` - [Google Chat](/channels/googlechat) — `channels.googlechat` - [Mattermost](/channels/mattermost) — `channels.mattermost` - - [MS Teams](/channels/msteams) — `channels.msteams` + - [Microsoft Teams](/channels/msteams) — `channels.msteams` All channels share the same DM policy pattern: diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 384b5311c33..e52f388b1ac 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -9,7 +9,7 @@ title: "Android App" # Android App (Node) -> **Note:** The Android app has not been publicly released yet. The source code is available in the [OpenClaw repository](https://github.com/openclaw/openclaw) under `apps/android`. You can build it yourself using Java 17 and the Android SDK (`./gradlew :app:assembleDebug`). See [apps/android/README.md](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md) for build instructions. +> **Note:** The Android app has not been publicly released yet. The source code is available in the [OpenClaw repository](https://github.com/openclaw/openclaw) under `apps/android`. You can build it yourself using Java 17 and the Android SDK (`./gradlew :app:assemblePlayDebug`). See [apps/android/README.md](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md) for build instructions. ## Support snapshot diff --git a/docs/tools/index.md b/docs/tools/index.md index 91297e5775c..075971d6877 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -448,12 +448,12 @@ For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf). ### `message` -Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams. +Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams. Core actions: -- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards) -- `poll` (WhatsApp/Discord/MS Teams polls) +- `send` (text + optional media; Microsoft Teams also supports `card` for Adaptive Cards) +- `poll` (WhatsApp/Discord/Microsoft Teams polls) - `react` / `reactions` / `read` / `edit` / `delete` - `pin` / `unpin` / `list-pins` - `permissions` @@ -471,7 +471,7 @@ Core actions: Notes: - `send` routes WhatsApp via the Gateway; other channels go direct. -- `poll` uses the Gateway for WhatsApp and MS Teams; Discord polls go direct. +- `poll` uses the Gateway for WhatsApp and Microsoft Teams; Discord polls go direct. - When a message tool call is bound to an active chat session, sends are constrained to that session’s target to avoid cross-context leaks. ### `cron` diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 16291eab32d..7f1ba0fade4 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -65,14 +65,14 @@ marketplace source with `--marketplace`. These are published to npm and installed with `openclaw plugins install`: -| Plugin | Package | Docs | -| --------------- | ---------------------- | ---------------------------------- | -| Matrix | `@openclaw/matrix` | [Matrix](/channels/matrix) | -| Microsoft Teams | `@openclaw/msteams` | [MS Teams](/channels/msteams) | -| Nostr | `@openclaw/nostr` | [Nostr](/channels/nostr) | -| Voice Call | `@openclaw/voice-call` | [Voice Call](/plugins/voice-call) | -| Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) | -| Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) | +| Plugin | Package | Docs | +| --------------- | ---------------------- | ------------------------------------ | +| Matrix | `@openclaw/matrix` | [Matrix](/channels/matrix) | +| Microsoft Teams | `@openclaw/msteams` | [Microsoft Teams](/channels/msteams) | +| Nostr | `@openclaw/nostr` | [Nostr](/channels/nostr) | +| Voice Call | `@openclaw/voice-call` | [Voice Call](/plugins/voice-call) | +| Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) | +| Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) | Microsoft Teams is plugin-only as of 2026.1.15. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 0910931b660..3881006829d 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -50,7 +50,7 @@ They run immediately, are stripped before the model sees the message, and the re ``` - `commands.text` (default `true`) enables parsing `/...` in chat messages. - - On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/MS Teams), text commands still work even if you set this to `false`. + - On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/Microsoft Teams), text commands still work even if you set this to `false`. - `commands.native` (default `"auto"`) registers native commands. - Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. - Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index 4e68d5a2803..50decf4d59d 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -4,6 +4,7 @@ import { DEFAULT_SEARCH_COUNT, MAX_SEARCH_COUNT, formatCliCommand, + mergeScopedSearchConfig, normalizeFreshness, normalizeToIsoDate, readCachedSearchPayload, @@ -607,21 +608,12 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createBraveToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "brave"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - ...(pluginConfig.apiKey === undefined ? {} : { apiKey: pluginConfig.apiKey }), - brave: { - ...resolveBraveConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "brave", + resolveProviderWebSearchPluginConfig(ctx.config, "brave"), + { mirrorApiKeyToTopLevel: true }, + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index 2772790878b..ff6fb310464 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -88,20 +88,17 @@ describe("monitorDiscordProvider", () => { const getConstructedEventQueue = (): { listenerTimeout?: number } | undefined => { expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1); const opts = clientConstructorOptionsMock.mock.calls[0]?.[0] as { - commandDeploymentMode?: string; eventQueue?: { listenerTimeout?: number }; }; return opts.eventQueue; }; const getConstructedClientOptions = (): { - commandDeploymentMode?: string; eventQueue?: { listenerTimeout?: number }; } => { expect(clientConstructorOptionsMock).toHaveBeenCalledTimes(1); return ( (clientConstructorOptionsMock.mock.calls[0]?.[0] as { - commandDeploymentMode?: string; eventQueue?: { listenerTimeout?: number }; }) ?? {} ); @@ -553,7 +550,7 @@ describe("monitorDiscordProvider", () => { ); }); - it("configures Carbon reconcile deployment by default", async () => { + it("configures Carbon native deploy by default", async () => { const { monitorDiscordProvider } = await import("./provider.js"); await monitorDiscordProvider({ @@ -562,7 +559,7 @@ describe("monitorDiscordProvider", () => { }); expect(clientHandleDeployRequestMock).toHaveBeenCalledTimes(1); - expect(getConstructedClientOptions().commandDeploymentMode).toBe("reconcile"); + expect(getConstructedClientOptions().eventQueue?.listenerTimeout).toBe(120_000); }); it("reports connected status on startup and shutdown", async () => { diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 55293357763..8dbb6df29f5 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -763,7 +763,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { baseUrl: "http://localhost", deploySecret: "a", clientId: applicationId, - commandDeploymentMode: "reconcile", publicKey: "a", token, autoDeploy: false, diff --git a/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts b/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts index b7b9a63dc70..d98bbec9e7c 100644 --- a/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.acp-init-failure.lifecycle.test.ts @@ -166,13 +166,6 @@ function createTopicEvent(messageId: string) { }; } -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - async function setupLifecycleMonitor() { const register = vi.fn((registered: Record Promise>) => { handlers = registered; @@ -201,6 +194,7 @@ async function setupLifecycleMonitor() { describe("Feishu ACP-init failure lifecycle", () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); handlers = {}; lastRuntime = null; @@ -334,6 +328,7 @@ describe("Feishu ACP-init failure lifecycle", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; return; @@ -346,9 +341,13 @@ describe("Feishu ACP-init failure lifecycle", () => { const event = createTopicEvent("om_topic_msg_1"); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + }); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).not.toHaveBeenCalled(); expect(resolveConfiguredBindingRouteMock).toHaveBeenCalledTimes(1); @@ -371,9 +370,13 @@ describe("Feishu ACP-init failure lifecycle", () => { const event = createTopicEvent("om_topic_msg_2"); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + }); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + }); expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); expect(lastRuntime?.error).not.toHaveBeenCalled(); diff --git a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts index 50c3b3d6f32..e235af4d8ec 100644 --- a/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.bot-menu.lifecycle.test.ts @@ -155,13 +155,6 @@ function createBotMenuEvent(params: { eventKey: string; timestamp: string }) { }; } -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - async function setupLifecycleMonitor() { const register = vi.fn((registered: Record Promise>) => { handlers = registered; @@ -190,6 +183,7 @@ async function setupLifecycleMonitor() { describe("Feishu bot-menu lifecycle", () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); handlers = {}; lastRuntime = null; @@ -292,6 +286,7 @@ describe("Feishu bot-menu lifecycle", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; return; @@ -307,9 +302,13 @@ describe("Feishu bot-menu lifecycle", () => { }); await onBotMenu(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + }); await onBotMenu(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).not.toHaveBeenCalled(); expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); @@ -332,9 +331,16 @@ describe("Feishu bot-menu lifecycle", () => { sendCardFeishuMock.mockRejectedValueOnce(new Error("boom")); await onBotMenu(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + }); await onBotMenu(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).not.toHaveBeenCalled(); expect(sendCardFeishuMock).toHaveBeenCalledTimes(1); diff --git a/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts b/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts index 3c1a51a084a..839ea934454 100644 --- a/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.broadcast.reply-once.lifecycle.test.ts @@ -184,13 +184,6 @@ function createBroadcastEvent(messageId: string) { }; } -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - async function setupLifecycleMonitor(accountId: "account-A" | "account-B") { const register = vi.fn((registered: Record Promise>) => { handlersByAccount.set(accountId, registered); @@ -220,6 +213,7 @@ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") { describe("Feishu broadcast reply-once lifecycle", () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); handlersByAccount = new Map(); runtimesByAccount = new Map(); @@ -327,6 +321,7 @@ describe("Feishu broadcast reply-once lifecycle", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; return; @@ -340,9 +335,14 @@ describe("Feishu broadcast reply-once lifecycle", () => { const event = createBroadcastEvent("om_broadcast_once"); await onMessageA(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0); + }); await onMessageB(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1); + }); expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled(); expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled(); @@ -383,9 +383,13 @@ describe("Feishu broadcast reply-once lifecycle", () => { }); await onMessageA(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock.mock.calls.length).toBeGreaterThan(0); + }); await onMessageB(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(2); + }); expect(runtimesByAccount.get("account-A")?.error).not.toHaveBeenCalled(); expect(runtimesByAccount.get("account-B")?.error).not.toHaveBeenCalled(); diff --git a/extensions/feishu/src/monitor.card-action.lifecycle.test.ts b/extensions/feishu/src/monitor.card-action.lifecycle.test.ts index e297fff9a09..c5908b29487 100644 --- a/extensions/feishu/src/monitor.card-action.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.card-action.lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; +import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js"; import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; import { monitorSingleAccount } from "./monitor.account.js"; import { setFeishuRuntime } from "./runtime.js"; @@ -181,13 +182,6 @@ function createCardActionEvent(params: { }; } -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - async function setupLifecycleMonitor() { const register = vi.fn((registered: Record Promise>) => { handlers = registered; @@ -216,9 +210,11 @@ async function setupLifecycleMonitor() { describe("Feishu card-action lifecycle", () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); handlers = {}; lastRuntime = null; + resetProcessedFeishuCardActionTokensForTests(); process.env.OPENCLAW_STATE_DIR = `/tmp/openclaw-feishu-card-action-${Date.now()}-${Math.random().toString(36).slice(2)}`; const dispatcher = { @@ -318,6 +314,8 @@ describe("Feishu card-action lifecycle", () => { }); afterEach(() => { + vi.useRealTimers(); + resetProcessedFeishuCardActionTokensForTests(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; return; @@ -334,9 +332,14 @@ describe("Feishu card-action lifecycle", () => { }); await onCardAction(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + }); await onCardAction(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).not.toHaveBeenCalled(); expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); @@ -379,9 +382,15 @@ describe("Feishu card-action lifecycle", () => { }); await onCardAction(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(lastRuntime?.error).toHaveBeenCalledTimes(1); + }); await onCardAction(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(lastRuntime?.error).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).toHaveBeenCalledTimes(1); expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); diff --git a/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts b/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts index e78f0b28a3c..4a965110613 100644 --- a/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts +++ b/extensions/feishu/src/monitor.reply-once.lifecycle.test.ts @@ -167,13 +167,6 @@ function createTextEvent(messageId: string) { }; } -async function settleAsyncWork(): Promise { - for (let i = 0; i < 6; i += 1) { - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } -} - async function setupLifecycleMonitor() { const register = vi.fn((registered: Record Promise>) => { handlers = registered; @@ -202,6 +195,7 @@ async function setupLifecycleMonitor() { describe("Feishu reply-once lifecycle", () => { beforeEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); handlers = {}; lastRuntime = null; @@ -304,6 +298,7 @@ describe("Feishu reply-once lifecycle", () => { }); afterEach(() => { + vi.useRealTimers(); if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; return; @@ -316,9 +311,14 @@ describe("Feishu reply-once lifecycle", () => { const event = createTextEvent("om_lifecycle_once"); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + }); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).not.toHaveBeenCalled(); expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); @@ -358,9 +358,15 @@ describe("Feishu reply-once lifecycle", () => { }); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(lastRuntime?.error).toHaveBeenCalledTimes(1); + }); await onMessage(event); - await settleAsyncWork(); + await vi.waitFor(() => { + expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); + expect(lastRuntime?.error).toHaveBeenCalledTimes(1); + }); expect(lastRuntime?.error).toHaveBeenCalledTimes(1); expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1); diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 11a0fa0788d..f91ae5f26d9 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -1,7 +1,9 @@ import { Type } from "@sinclair/typebox"; import { enablePluginInConfig, + getScopedCredentialValue, resolveProviderWebSearchPluginConfig, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search"; @@ -21,26 +23,6 @@ const GenericFirecrawlSearchSchema = Type.Object( { additionalProperties: false }, ); -function getScopedCredentialValue(searchConfig?: Record): unknown { - const scoped = searchConfig?.firecrawl; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - return undefined; - } - return (scoped as Record).apiKey; -} - -function setScopedCredentialValue( - searchConfigTarget: Record, - value: unknown, -): void { - const scoped = searchConfigTarget.firecrawl; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.firecrawl = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; -} - export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { return { id: "firecrawl", @@ -53,8 +35,9 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 60, credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"], - getCredentialValue: getScopedCredentialValue, - setCredentialValue: setScopedCredentialValue, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "firecrawl", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 3c7be2e7dfd..c316896953c 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -1,8 +1,11 @@ import { Type } from "@sinclair/typebox"; import { buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, + mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -13,6 +16,7 @@ import { resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type SearchConfigRecord, type WebSearchProviderPlugin, @@ -177,22 +181,9 @@ function createGeminiToolDefinition( parameters: createGeminiSchema(), execute: async (args) => { const params = args as Record; - for (const name of ["country", "language", "freshness", "date_after", "date_before"]) { - if (readStringParam(params, name)) { - const label = - name === "country" - ? "country filtering" - : name === "language" - ? "language filtering" - : name === "freshness" - ? "freshness filtering" - : "date_after/date_before filtering"; - return { - error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`, - message: `${label} is not supported by the gemini provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`, - docs: "https://docs.openclaw.ai/tools/web", - }; - } + const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini"); + if (unsupportedResponse) { + return unsupportedResponse; } const geminiConfig = resolveGeminiConfig(searchConfig); @@ -262,20 +253,9 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 20, credentialPath: "plugins.entries.google.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const gemini = searchConfig?.gemini; - return gemini && typeof gemini === "object" && !Array.isArray(gemini) - ? (gemini as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.gemini; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.gemini = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "gemini", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "google")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -283,20 +263,11 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createGeminiToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "google"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - gemini: { - ...resolveGeminiConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "gemini", + resolveProviderWebSearchPluginConfig(ctx.config, "google"), + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/moonshot/src/kimi-web-search-provider.ts b/extensions/moonshot/src/kimi-web-search-provider.ts index db35822fbba..cca3bcf7aa8 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.ts @@ -1,8 +1,11 @@ import { Type } from "@sinclair/typebox"; import { buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, + mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -12,6 +15,7 @@ import { resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type SearchConfigRecord, type WebSearchProviderPlugin, @@ -246,22 +250,9 @@ function createKimiToolDefinition( parameters: createKimiSchema(), execute: async (args) => { const params = args as Record; - for (const name of ["country", "language", "freshness", "date_after", "date_before"]) { - if (readStringParam(params, name)) { - const label = - name === "country" - ? "country filtering" - : name === "language" - ? "language filtering" - : name === "freshness" - ? "freshness filtering" - : "date_after/date_before filtering"; - return { - error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`, - message: `${label} is not supported by the kimi provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`, - docs: "https://docs.openclaw.ai/tools/web", - }; - } + const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "kimi"); + if (unsupportedResponse) { + return unsupportedResponse; } const kimiConfig = resolveKimiConfig(searchConfig); @@ -334,20 +325,9 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 40, credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const kimi = searchConfig?.kimi; - return kimi && typeof kimi === "object" && !Array.isArray(kimi) - ? (kimi as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.kimi; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.kimi = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "kimi", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -355,20 +335,11 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createKimiToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - kimi: { - ...resolveKimiConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "kimi", + resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"), + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index a7b4b12e94c..6fba3b4b03f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -7,8 +7,10 @@ import { import { buildSearchCacheKey, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, isoToPerplexityDate, + mergeScopedSearchConfig, normalizeFreshness, normalizeToIsoDate, readCachedSearchPayload, @@ -19,6 +21,7 @@ import { resolveSearchCount, resolveSearchTimeoutSeconds, resolveSiteName, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, throwWebSearchApiError, type SearchConfigRecord, @@ -658,20 +661,9 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 50, credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const perplexity = searchConfig?.perplexity; - return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.perplexity; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.perplexity = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "perplexity", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -679,17 +671,11 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { }, resolveRuntimeMetadata: (ctx) => ({ perplexityTransport: resolveRuntimeTransport({ - searchConfig: { - ...(ctx.searchConfig as SearchConfigRecord | undefined), - perplexity: { - ...((ctx.searchConfig as SearchConfigRecord | undefined)?.perplexity as - | Record - | undefined), - ...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as - | Record - | undefined), - }, - }, + searchConfig: mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "perplexity", + resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"), + ) as SearchConfigRecord | undefined, resolvedKey: ctx.resolvedCredential?.value, keySource: ctx.resolvedCredential?.source ?? "missing", fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar, @@ -697,20 +683,11 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { }), createTool: (ctx) => createPerplexityToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - perplexity: { - ...resolvePerplexityConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "perplexity", + resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"), + ) as SearchConfigRecord | undefined, ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined, ), }; diff --git a/extensions/tavily/src/tavily-search-provider.ts b/extensions/tavily/src/tavily-search-provider.ts index 2ad33362353..4ed5fedd783 100644 --- a/extensions/tavily/src/tavily-search-provider.ts +++ b/extensions/tavily/src/tavily-search-provider.ts @@ -1,7 +1,9 @@ import { Type } from "@sinclair/typebox"; import { enablePluginInConfig, + getScopedCredentialValue, resolveProviderWebSearchPluginConfig, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search"; @@ -21,26 +23,6 @@ const GenericTavilySearchSchema = Type.Object( { additionalProperties: false }, ); -function getScopedCredentialValue(searchConfig?: Record): unknown { - const scoped = searchConfig?.tavily; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - return undefined; - } - return (scoped as Record).apiKey; -} - -function setScopedCredentialValue( - searchConfigTarget: Record, - value: unknown, -): void { - const scoped = searchConfigTarget.tavily; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.tavily = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; -} - export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { return { id: "tavily", @@ -53,8 +35,9 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 70, credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], - getCredentialValue: getScopedCredentialValue, - setCredentialValue: setScopedCredentialValue, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "tavily"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "tavily", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { diff --git a/extensions/telegram/src/thread-bindings.test.ts b/extensions/telegram/src/thread-bindings.test.ts index 39b9c63338b..cc9bd2a1209 100644 --- a/extensions/telegram/src/thread-bindings.test.ts +++ b/extensions/telegram/src/thread-bindings.test.ts @@ -15,12 +15,13 @@ import { describe("telegram thread bindings", () => { let stateDirOverride: string | undefined; - beforeEach(() => { - __testing.resetTelegramThreadBindingsForTests(); + beforeEach(async () => { + await __testing.resetTelegramThreadBindingsForTests(); }); - afterEach(() => { + afterEach(async () => { vi.useRealTimers(); + await __testing.resetTelegramThreadBindingsForTests(); if (stateDirOverride) { delete process.env.OPENCLAW_STATE_DIR; fs.rmSync(stateDirOverride, { recursive: true, force: true }); @@ -90,7 +91,7 @@ describe("telegram thread bindings", () => { "./thread-bindings.js?scope=shared-b", ); - bindingsA.__testing.resetTelegramThreadBindingsForTests(); + await bindingsA.__testing.resetTelegramThreadBindingsForTests(); try { const managerA = bindingsA.createTelegramThreadBindingManager({ @@ -123,7 +124,7 @@ describe("telegram thread bindings", () => { ?.getByConversationId("-100200300:topic:44")?.targetSessionKey, ).toBe("agent:main:subagent:child-shared"); } finally { - bindingsA.__testing.resetTelegramThreadBindingsForTests(); + await bindingsA.__testing.resetTelegramThreadBindingsForTests(); } }); @@ -237,7 +238,7 @@ describe("telegram thread bindings", () => { reason: "test-detach", }); - __testing.resetTelegramThreadBindingsForTests(); + await __testing.resetTelegramThreadBindingsForTests(); const reloaded = createTelegramThreadBindingManager({ accountId: "default", @@ -247,4 +248,45 @@ describe("telegram thread bindings", () => { expect(reloaded.getByConversationId("8460800771")).toBeUndefined(); }); + + it("flushes pending lifecycle update persists before test reset", async () => { + stateDirOverride = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-bindings-")); + process.env.OPENCLAW_STATE_DIR = stateDirOverride; + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); + + createTelegramThreadBindingManager({ + accountId: "persist-reset", + persist: true, + enableSweeper: false, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:main:subagent:child-3", + targetKind: "subagent", + conversation: { + channel: "telegram", + accountId: "persist-reset", + conversationId: "-100200300:topic:99", + }, + }); + + setTelegramThreadBindingIdleTimeoutBySessionKey({ + accountId: "persist-reset", + targetSessionKey: "agent:main:subagent:child-3", + idleTimeoutMs: 90_000, + }); + + await __testing.resetTelegramThreadBindingsForTests(); + + const statePath = path.join( + resolveStateDir(process.env, os.homedir), + "telegram", + "thread-bindings-persist-reset.json", + ); + const persisted = JSON.parse(fs.readFileSync(statePath, "utf8")) as { + bindings?: Array<{ idleTimeoutMs?: number }>; + }; + expect(persisted.bindings?.[0]?.idleTimeoutMs).toBe(90_000); + }); }); diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts index aaf13e15561..8b7be041197 100644 --- a/extensions/telegram/src/thread-bindings.ts +++ b/extensions/telegram/src/thread-bindings.ts @@ -67,6 +67,7 @@ export type TelegramThreadBindingManager = { type TelegramThreadBindingsState = { managersByAccountId: Map; bindingsByAccountConversation: Map; + persistQueueByAccountId: Map>; }; /** @@ -80,10 +81,12 @@ const threadBindingsState = resolveGlobalSingleton( () => ({ managersByAccountId: new Map(), bindingsByAccountConversation: new Map(), + persistQueueByAccountId: new Map>(), }), ); const MANAGERS_BY_ACCOUNT_ID = threadBindingsState.managersByAccountId; const BINDINGS_BY_ACCOUNT_CONVERSATION = threadBindingsState.bindingsByAccountConversation; +const PERSIST_QUEUE_BY_ACCOUNT_ID = threadBindingsState.persistQueueByAccountId; function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { @@ -323,16 +326,18 @@ function loadBindingsFromDisk(accountId: string): TelegramThreadBindingRecord[] async function persistBindingsToDisk(params: { accountId: string; persist: boolean; + bindings?: TelegramThreadBindingRecord[]; }): Promise { if (!params.persist) { return; } - const bindings = [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( - (entry) => entry.accountId === params.accountId, - ); const payload: StoredTelegramBindingState = { version: STORE_VERSION, - bindings, + bindings: + params.bindings ?? + [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (entry) => entry.accountId === params.accountId, + ), }; await writeJsonAtomic(resolveBindingsPath(params.accountId), payload, { mode: 0o600, @@ -341,6 +346,48 @@ async function persistBindingsToDisk(params: { }); } +function listBindingsForAccount(accountId: string): TelegramThreadBindingRecord[] { + return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (entry) => entry.accountId === accountId, + ); +} + +function enqueuePersistBindings(params: { + accountId: string; + persist: boolean; + bindings?: TelegramThreadBindingRecord[]; +}): Promise { + if (!params.persist) { + return Promise.resolve(); + } + const previous = PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) ?? Promise.resolve(); + const next = previous + .catch(() => undefined) + .then(async () => { + await persistBindingsToDisk(params); + }); + PERSIST_QUEUE_BY_ACCOUNT_ID.set(params.accountId, next); + void next.finally(() => { + if (PERSIST_QUEUE_BY_ACCOUNT_ID.get(params.accountId) === next) { + PERSIST_QUEUE_BY_ACCOUNT_ID.delete(params.accountId); + } + }); + return next; +} + +function persistBindingsSafely(params: { + accountId: string; + persist: boolean; + bindings?: TelegramThreadBindingRecord[]; + reason: string; +}): void { + void enqueuePersistBindings(params).catch((err) => { + logVerbose( + `telegram thread bindings persist failed (${params.accountId}, ${params.reason}): ${String(err)}`, + ); + }); +} + function normalizeTimestampMs(raw: unknown): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return Date.now(); @@ -414,9 +461,6 @@ export function createTelegramThreadBindingManager( }); } - const listBindingsForAccount = () => - [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter((entry) => entry.accountId === accountId); - let sweepTimer: NodeJS.Timeout | null = null; const manager: TelegramThreadBindingManager = { @@ -441,11 +485,11 @@ export function createTelegramThreadBindingManager( if (!targetSessionKey) { return []; } - return listBindingsForAccount().filter( + return listBindingsForAccount(accountId).filter( (entry) => entry.targetSessionKey === targetSessionKey, ); }, - listBindings: () => listBindingsForAccount(), + listBindings: () => listBindingsForAccount(accountId), touchConversation: (conversationIdRaw, at) => { const conversationId = normalizeConversationId(conversationIdRaw); if (!conversationId) { @@ -461,7 +505,12 @@ export function createTelegramThreadBindingManager( lastActivityAt: normalizeTimestampMs(at ?? Date.now()), }; BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, nextRecord); - void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + persistBindingsSafely({ + accountId, + persist: manager.shouldPersistMutations(), + bindings: listBindingsForAccount(accountId), + reason: "touch", + }); return nextRecord; }, unbindConversation: (unbindParams) => { @@ -475,7 +524,12 @@ export function createTelegramThreadBindingManager( return null; } BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); - void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + persistBindingsSafely({ + accountId, + persist: manager.shouldPersistMutations(), + bindings: listBindingsForAccount(accountId), + reason: "unbind-conversation", + }); return removed; }, unbindBySessionKey: (unbindParams) => { @@ -484,7 +538,7 @@ export function createTelegramThreadBindingManager( return []; } const removed: TelegramThreadBindingRecord[] = []; - for (const entry of listBindingsForAccount()) { + for (const entry of listBindingsForAccount(accountId)) { if (entry.targetSessionKey !== targetSessionKey) { continue; } @@ -496,7 +550,12 @@ export function createTelegramThreadBindingManager( removed.push(entry); } if (removed.length > 0) { - void persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + persistBindingsSafely({ + accountId, + persist: manager.shouldPersistMutations(), + bindings: listBindingsForAccount(accountId), + reason: "unbind-session", + }); } return removed; }, @@ -544,7 +603,11 @@ export function createTelegramThreadBindingManager( resolveBindingKey({ accountId, conversationId }), record, ); - await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + await enqueuePersistBindings({ + accountId, + persist: manager.shouldPersistMutations(), + bindings: listBindingsForAccount(accountId), + }); logVerbose( `telegram: bound conversation ${conversationId} -> ${targetSessionKey} (${summarizeLifecycleForLog( record, @@ -605,7 +668,11 @@ export function createTelegramThreadBindingManager( sendFarewell: false, }); if (removed.length > 0) { - await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + await enqueuePersistBindings({ + accountId, + persist: manager.shouldPersistMutations(), + bindings: listBindingsForAccount(accountId), + }); } return removed.map((entry) => toSessionBindingRecord(entry, { @@ -627,7 +694,11 @@ export function createTelegramThreadBindingManager( sendFarewell: false, }); if (removed) { - await persistBindingsToDisk({ accountId, persist: manager.shouldPersistMutations() }); + await enqueuePersistBindings({ + accountId, + persist: manager.shouldPersistMutations(), + bindings: listBindingsForAccount(accountId), + }); } return removed ? [ @@ -644,7 +715,7 @@ export function createTelegramThreadBindingManager( if (sweeperEnabled) { sweepTimer = setInterval(() => { const now = Date.now(); - for (const record of listBindingsForAccount()) { + for (const record of listBindingsForAccount(accountId)) { const idleExpired = shouldExpireByIdle({ now, record, @@ -699,9 +770,11 @@ function updateTelegramBindingsBySessionKey(params: { updated.push(next); } if (updated.length > 0) { - void persistBindingsToDisk({ + persistBindingsSafely({ accountId: params.manager.accountId, persist: params.manager.shouldPersistMutations(), + bindings: listBindingsForAccount(params.manager.accountId), + reason: "session-lifecycle-update", }); } return updated; @@ -750,10 +823,12 @@ export function setTelegramThreadBindingMaxAgeBySessionKey(params: { } export const __testing = { - resetTelegramThreadBindingsForTests() { + async resetTelegramThreadBindingsForTests() { for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { manager.stop(); } + await Promise.allSettled(PERSIST_QUEUE_BY_ACCOUNT_ID.values()); + PERSIST_QUEUE_BY_ACCOUNT_ID.clear(); MANAGERS_BY_ACCOUNT_ID.clear(); BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); }, diff --git a/extensions/xai/src/grok-web-search-provider.ts b/extensions/xai/src/grok-web-search-provider.ts index 11c1439f2d0..d9a6f0f8d46 100644 --- a/extensions/xai/src/grok-web-search-provider.ts +++ b/extensions/xai/src/grok-web-search-provider.ts @@ -1,8 +1,11 @@ import { Type } from "@sinclair/typebox"; import { buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, + getScopedCredentialValue, MAX_SEARCH_COUNT, + mergeScopedSearchConfig, readCachedSearchPayload, readConfiguredSecretString, readNumberParam, @@ -12,6 +15,7 @@ import { resolveSearchCacheTtlMs, resolveSearchCount, resolveSearchTimeoutSeconds, + setScopedCredentialValue, setProviderWebSearchPluginConfigValue, type SearchConfigRecord, type WebSearchProviderPlugin, @@ -188,22 +192,9 @@ function createGrokToolDefinition( parameters: createGrokSchema(), execute: async (args) => { const params = args as Record; - for (const name of ["country", "language", "freshness", "date_after", "date_before"]) { - if (readStringParam(params, name)) { - const label = - name === "country" - ? "country filtering" - : name === "language" - ? "language filtering" - : name === "freshness" - ? "freshness filtering" - : "date_after/date_before filtering"; - return { - error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`, - message: `${label} is not supported by the grok provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`, - docs: "https://docs.openclaw.ai/tools/web", - }; - } + const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "grok"); + if (unsupportedResponse) { + return unsupportedResponse; } const grokConfig = resolveGrokConfig(searchConfig); @@ -277,20 +268,9 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin { autoDetectOrder: 30, credentialPath: "plugins.entries.xai.config.webSearch.apiKey", inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => { - const grok = searchConfig?.grok; - return grok && typeof grok === "object" && !Array.isArray(grok) - ? (grok as Record).apiKey - : undefined; - }, - setCredentialValue: (searchConfigTarget, value) => { - const scoped = searchConfigTarget.grok; - if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) { - searchConfigTarget.grok = { apiKey: value }; - return; - } - (scoped as Record).apiKey = value; - }, + getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "grok"), + setCredentialValue: (searchConfigTarget, value) => + setScopedCredentialValue(searchConfigTarget, "grok", value), getConfiguredCredentialValue: (config) => resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey, setConfiguredCredentialValue: (configTarget, value) => { @@ -298,20 +278,11 @@ export function createGrokWebSearchProvider(): WebSearchProviderPlugin { }, createTool: (ctx) => createGrokToolDefinition( - (() => { - const searchConfig = ctx.searchConfig as SearchConfigRecord | undefined; - const pluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "xai"); - if (!pluginConfig) { - return searchConfig; - } - return { - ...(searchConfig ?? {}), - grok: { - ...resolveGrokConfig(searchConfig), - ...pluginConfig, - }, - } as SearchConfigRecord; - })(), + mergeScopedSearchConfig( + ctx.searchConfig as SearchConfigRecord | undefined, + "grok", + resolveProviderWebSearchPluginConfig(ctx.config, "xai"), + ) as SearchConfigRecord | undefined, ), }; } diff --git a/extensions/zalouser/src/accounts.test-mocks.ts b/extensions/zalouser/src/accounts.test-mocks.ts index 0206095d0fc..9e8e1f14de3 100644 --- a/extensions/zalouser/src/accounts.test-mocks.ts +++ b/extensions/zalouser/src/accounts.test-mocks.ts @@ -1,10 +1,14 @@ import { vi } from "vitest"; import { createDefaultResolvedZalouserAccount } from "./test-helpers.js"; -vi.mock("./accounts.js", async (importOriginal) => { - const actual = (await importOriginal()) as Record; +vi.mock("./accounts.js", () => { return { - ...actual, + listZalouserAccountIds: () => ["default"], + resolveDefaultZalouserAccountId: () => "default", resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(), + resolveZalouserAccount: async () => createDefaultResolvedZalouserAccount(), + listEnabledZalouserAccounts: async () => [createDefaultResolvedZalouserAccount()], + getZcaUserInfo: async () => null, + checkZcaAuthenticated: async () => false, }; }); diff --git a/extensions/zalouser/src/channel.directory.test.ts b/extensions/zalouser/src/channel.directory.test.ts index 1736118bc0e..8beb8a8d623 100644 --- a/extensions/zalouser/src/channel.directory.test.ts +++ b/extensions/zalouser/src/channel.directory.test.ts @@ -1,18 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import "./accounts.test-mocks.js"; -import { createZalouserRuntimeEnv } from "./test-helpers.js"; - -const listZaloGroupMembersMock = vi.hoisted(() => vi.fn(async () => [])); - -vi.mock("./zalo-js.js", async (importOriginal) => { - const actual = (await importOriginal()) as Record; - return { - ...actual, - listZaloGroupMembers: listZaloGroupMembersMock, - }; -}); - +import "./zalo-js.test-mocks.js"; import { zalouserPlugin } from "./channel.js"; +import { createZalouserRuntimeEnv } from "./test-helpers.js"; +import { listZaloGroupMembersMock } from "./zalo-js.test-mocks.js"; const runtimeStub = createZalouserRuntimeEnv(); diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 207707a5bd8..5054fd53941 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js"; import "./accounts.test-mocks.js"; +import "./zalo-js.test-mocks.js"; import type { ReplyPayload } from "../runtime-api.js"; import { zalouserPlugin } from "./channel.js"; import { setZalouserRuntime } from "./runtime.js"; diff --git a/extensions/zalouser/src/channel.setup.test.ts b/extensions/zalouser/src/channel.setup.test.ts index 552a45c882e..75aebe5e6be 100644 --- a/extensions/zalouser/src/channel.setup.test.ts +++ b/extensions/zalouser/src/channel.setup.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { withEnvAsync } from "../../../test/helpers/extensions/env.js"; +import "./zalo-js.test-mocks.js"; import { zalouserSetupPlugin } from "./channel.setup.js"; const zalouserSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index 23ef1809e25..012b970810a 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./zalo-js.test-mocks.js"; import { zalouserPlugin } from "./channel.js"; import { setZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; diff --git a/extensions/zalouser/src/monitor.account-scope.test.ts b/extensions/zalouser/src/monitor.account-scope.test.ts index 5119d57f69b..69f77c4b2d5 100644 --- a/extensions/zalouser/src/monitor.account-scope.test.ts +++ b/extensions/zalouser/src/monitor.account-scope.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import "./monitor.send-mocks.js"; +import "./zalo-js.test-mocks.js"; import { __testing } from "./monitor.js"; import { sendMessageZalouserMock } from "./monitor.send-mocks.js"; import { setZalouserRuntime } from "./runtime.js"; diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index bc21914417f..7f6eac47487 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js"; import "./monitor.send-mocks.js"; +import "./zalo-js.test-mocks.js"; import { resolveZalouserAccountSync } from "./accounts.js"; import { __testing } from "./monitor.js"; import { diff --git a/extensions/zalouser/src/reaction.ts b/extensions/zalouser/src/reaction.ts index 0579df86ce5..5739fe1cd50 100644 --- a/extensions/zalouser/src/reaction.ts +++ b/extensions/zalouser/src/reaction.ts @@ -1,4 +1,4 @@ -import { Reactions } from "./zca-client.js"; +import { Reactions } from "./zca-constants.js"; const REACTION_ALIAS_MAP = new Map([ ["like", Reactions.LIKE], diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index cc920e6be7e..ecbaff5982d 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -17,7 +17,7 @@ import { sendZaloTextMessage, sendZaloTypingEvent, } from "./zalo-js.js"; -import { TextStyle } from "./zca-client.js"; +import { TextStyle } from "./zca-constants.js"; vi.mock("./zalo-js.js", () => ({ sendZaloTextMessage: vi.fn(), diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 55ff17df636..b730c1a1a96 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -8,7 +8,7 @@ import { sendZaloTextMessage, sendZaloTypingEvent, } from "./zalo-js.js"; -import { TextStyle } from "./zca-client.js"; +import { TextStyle } from "./zca-constants.js"; export type ZalouserSendOptions = ZaloSendOptions; export type ZalouserSendResult = ZaloSendResult; diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts index e04590b9dba..14030a60936 100644 --- a/extensions/zalouser/src/setup-surface.test.ts +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -3,30 +3,7 @@ import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/chan import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; import { createTestWizardPrompter } from "../../../test/helpers/extensions/setup-wizard.js"; import type { OpenClawConfig } from "../runtime-api.js"; - -vi.mock("./zalo-js.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - checkZaloAuthenticated: vi.fn(async () => false), - logoutZaloProfile: vi.fn(async () => {}), - startZaloQrLogin: vi.fn(async () => ({ - message: "qr pending", - qrDataUrl: undefined, - })), - waitForZaloQrLogin: vi.fn(async () => ({ - connected: false, - message: "login pending", - })), - resolveZaloAllowFromEntries: vi.fn(async ({ entries }: { entries: string[] }) => - entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), - ), - resolveZaloGroupsByEntries: vi.fn(async ({ entries }: { entries: string[] }) => - entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), - ), - }; -}); - +import "./zalo-js.test-mocks.js"; import { zalouserPlugin } from "./channel.js"; const zalouserConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({ diff --git a/extensions/zalouser/src/text-styles.test.ts b/extensions/zalouser/src/text-styles.test.ts index 01e6c2da86b..b2540f74bb6 100644 --- a/extensions/zalouser/src/text-styles.test.ts +++ b/extensions/zalouser/src/text-styles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { parseZalouserTextStyles } from "./text-styles.js"; -import { TextStyle } from "./zca-client.js"; +import { TextStyle } from "./zca-constants.js"; describe("parseZalouserTextStyles", () => { it("renders inline markdown emphasis as Zalo style ranges", () => { diff --git a/extensions/zalouser/src/text-styles.ts b/extensions/zalouser/src/text-styles.ts index cdfe8b492b5..f352c5d239e 100644 --- a/extensions/zalouser/src/text-styles.ts +++ b/extensions/zalouser/src/text-styles.ts @@ -1,4 +1,4 @@ -import { TextStyle, type Style } from "./zca-client.js"; +import { TextStyle, type Style } from "./zca-constants.js"; type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle]; diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index 08dc2fd8d12..aaf9b9b44b7 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -1,4 +1,4 @@ -import type { Style } from "./zca-client.js"; +import type { Style } from "./zca-constants.js"; export type ZcaFriend = { userId: string; diff --git a/extensions/zalouser/src/zalo-js.test-mocks.ts b/extensions/zalouser/src/zalo-js.test-mocks.ts new file mode 100644 index 00000000000..2b9853a26d7 --- /dev/null +++ b/extensions/zalouser/src/zalo-js.test-mocks.ts @@ -0,0 +1,60 @@ +import { vi } from "vitest"; + +const zaloJsMocks = vi.hoisted(() => ({ + checkZaloAuthenticatedMock: vi.fn(async () => false), + getZaloUserInfoMock: vi.fn(async () => null), + listZaloFriendsMock: vi.fn(async () => []), + listZaloFriendsMatchingMock: vi.fn(async () => []), + listZaloGroupMembersMock: vi.fn(async () => []), + listZaloGroupsMock: vi.fn(async () => []), + listZaloGroupsMatchingMock: vi.fn(async () => []), + logoutZaloProfileMock: vi.fn(async () => {}), + resolveZaloAllowFromEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + resolveZaloGroupContextMock: vi.fn(async () => null), + resolveZaloGroupsByEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + startZaloListenerMock: vi.fn(async () => ({ stop: vi.fn() })), + startZaloQrLoginMock: vi.fn(async () => ({ + message: "qr pending", + qrDataUrl: undefined, + })), + waitForZaloQrLoginMock: vi.fn(async () => ({ + connected: false, + message: "login pending", + })), +})); + +export const checkZaloAuthenticatedMock = zaloJsMocks.checkZaloAuthenticatedMock; +export const getZaloUserInfoMock = zaloJsMocks.getZaloUserInfoMock; +export const listZaloFriendsMock = zaloJsMocks.listZaloFriendsMock; +export const listZaloFriendsMatchingMock = zaloJsMocks.listZaloFriendsMatchingMock; +export const listZaloGroupMembersMock = zaloJsMocks.listZaloGroupMembersMock; +export const listZaloGroupsMock = zaloJsMocks.listZaloGroupsMock; +export const listZaloGroupsMatchingMock = zaloJsMocks.listZaloGroupsMatchingMock; +export const logoutZaloProfileMock = zaloJsMocks.logoutZaloProfileMock; +export const resolveZaloAllowFromEntriesMock = zaloJsMocks.resolveZaloAllowFromEntriesMock; +export const resolveZaloGroupContextMock = zaloJsMocks.resolveZaloGroupContextMock; +export const resolveZaloGroupsByEntriesMock = zaloJsMocks.resolveZaloGroupsByEntriesMock; +export const startZaloListenerMock = zaloJsMocks.startZaloListenerMock; +export const startZaloQrLoginMock = zaloJsMocks.startZaloQrLoginMock; +export const waitForZaloQrLoginMock = zaloJsMocks.waitForZaloQrLoginMock; + +vi.mock("./zalo-js.js", () => ({ + checkZaloAuthenticated: checkZaloAuthenticatedMock, + getZaloUserInfo: getZaloUserInfoMock, + listZaloFriends: listZaloFriendsMock, + listZaloFriendsMatching: listZaloFriendsMatchingMock, + listZaloGroupMembers: listZaloGroupMembersMock, + listZaloGroups: listZaloGroupsMock, + listZaloGroupsMatching: listZaloGroupsMatchingMock, + logoutZaloProfile: logoutZaloProfileMock, + resolveZaloAllowFromEntries: resolveZaloAllowFromEntriesMock, + resolveZaloGroupContext: resolveZaloGroupContextMock, + resolveZaloGroupsByEntries: resolveZaloGroupsByEntriesMock, + startZaloListener: startZaloListenerMock, + startZaloQrLogin: startZaloQrLoginMock, + waitForZaloQrLogin: waitForZaloQrLoginMock, +})); diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index 3d1a146ea9f..e8e6c3659f6 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -19,17 +19,16 @@ import type { ZcaUserInfo, } from "./types.js"; import { - LoginQRCallbackEventType, TextStyle, - ThreadType, - Zalo, type API, type Credentials, type GroupInfo, type LoginQRCallbackEvent, type Message, type User, + Zalo, } from "./zca-client.js"; +import { LoginQRCallbackEventType, ThreadType } from "./zca-constants.js"; const API_LOGIN_TIMEOUT_MS = 20_000; const QR_LOGIN_TTL_MS = 3 * 60_000; diff --git a/extensions/zalouser/src/zca-client.ts b/extensions/zalouser/src/zca-client.ts index f7bc1a358b3..bae0472fc09 100644 --- a/extensions/zalouser/src/zca-client.ts +++ b/extensions/zalouser/src/zca-client.ts @@ -1,67 +1,17 @@ import * as zcaJsRuntime from "zca-js"; +import { + LoginQRCallbackEventType, + Reactions, + TextStyle, + ThreadType, + type Style, +} from "./zca-constants.js"; const zcaJs = zcaJsRuntime as unknown as { - ThreadType: unknown; - LoginQRCallbackEventType: unknown; - Reactions: unknown; Zalo: unknown; }; - -export const ThreadType = zcaJs.ThreadType as { - User: 0; - Group: 1; -}; - -export const LoginQRCallbackEventType = zcaJs.LoginQRCallbackEventType as { - QRCodeGenerated: 0; - QRCodeExpired: 1; - QRCodeScanned: 2; - QRCodeDeclined: 3; - GotLoginInfo: 4; -}; - -export const Reactions = zcaJs.Reactions as Record & { - HEART: string; - LIKE: string; - HAHA: string; - WOW: string; - CRY: string; - ANGRY: string; - NONE: string; -}; - -// Mirror zca-js sendMessage style constants locally because the package root -// typing surface does not consistently expose TextStyle/Style to tsgo. -export const TextStyle = { - Bold: "b", - Italic: "i", - Underline: "u", - StrikeThrough: "s", - Red: "c_db342e", - Orange: "c_f27806", - Yellow: "c_f7b503", - Green: "c_15a85f", - Small: "f_13", - Big: "f_18", - UnorderedList: "lst_1", - OrderedList: "lst_2", - Indent: "ind_$", -} as const; - -type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle]; - -export type Style = - | { - start: number; - len: number; - st: Exclude; - } - | { - start: number; - len: number; - st: typeof TextStyle.Indent; - indentSize?: number; - }; +export { LoginQRCallbackEventType, Reactions, TextStyle, ThreadType }; +export type { Style }; export type Credentials = { imei: string; diff --git a/extensions/zalouser/src/zca-constants.ts b/extensions/zalouser/src/zca-constants.ts new file mode 100644 index 00000000000..ec906427e34 --- /dev/null +++ b/extensions/zalouser/src/zca-constants.ts @@ -0,0 +1,55 @@ +export const ThreadType = { + User: 0, + Group: 1, +} as const; + +export const LoginQRCallbackEventType = { + QRCodeGenerated: 0, + QRCodeExpired: 1, + QRCodeScanned: 2, + QRCodeDeclined: 3, + GotLoginInfo: 4, +} as const; + +export const Reactions = { + HEART: "/-heart", + LIKE: "/-strong", + HAHA: ":>", + WOW: ":o", + CRY: ":-((", + ANGRY: ":-h", + NONE: "", +} as const; + +// Mirror zca-js sendMessage style constants locally because the package root +// typing surface does not consistently expose TextStyle/Style to tsgo. +export const TextStyle = { + Bold: "b", + Italic: "i", + Underline: "u", + StrikeThrough: "s", + Red: "c_db342e", + Orange: "c_f27806", + Yellow: "c_f7b503", + Green: "c_15a85f", + Small: "f_13", + Big: "f_18", + UnorderedList: "lst_1", + OrderedList: "lst_2", + Indent: "ind_$", +} as const; + +type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle]; + +export type Style = + | { + start: number; + len: number; + st: Exclude; + } + | { + start: number; + len: number; + st: typeof TextStyle.Indent; + indentSize?: number; + }; diff --git a/package.json b/package.json index e7142b76a54..ed8cc402625 100644 --- a/package.json +++ b/package.json @@ -528,15 +528,19 @@ "./cli-entry": "./openclaw.mjs" }, "scripts": { - "android:assemble": "cd apps/android && ./gradlew :app:assembleDebug", + "android:assemble": "cd apps/android && ./gradlew :app:assemblePlayDebug", + "android:assemble:third-party": "cd apps/android && ./gradlew :app:assembleThirdPartyDebug", "android:bundle:release": "bun apps/android/scripts/build-release-aab.ts", "android:format": "cd apps/android && ./gradlew :app:ktlintFormat :benchmark:ktlintFormat", - "android:install": "cd apps/android && ./gradlew :app:installDebug", + "android:install": "cd apps/android && ./gradlew :app:installPlayDebug", + "android:install:third-party": "cd apps/android && ./gradlew :app:installThirdPartyDebug", "android:lint": "cd apps/android && ./gradlew :app:ktlintCheck :benchmark:ktlintCheck", "android:lint:android": "cd apps/android && ./gradlew :app:lintDebug", - "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.app/.MainActivity", - "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", + "android:run": "cd apps/android && ./gradlew :app:installPlayDebug && adb shell am start -n ai.openclaw.app/.MainActivity", + "android:run:third-party": "cd apps/android && ./gradlew :app:installThirdPartyDebug && adb shell am start -n ai.openclaw.app/.MainActivity", + "android:test": "cd apps/android && ./gradlew :app:testPlayDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", + "android:test:third-party": "cd apps/android && ./gradlew :app:testThirdPartyDebugUnitTest", "build": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:docker": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", @@ -669,6 +673,7 @@ "test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh", "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", + "test:perf:update-memory-hotspots": "node scripts/test-update-memory-hotspots.mjs", "test:perf:update-timings": "node scripts/test-update-timings.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", diff --git a/scripts/ci-changed-scope.mjs b/scripts/ci-changed-scope.mjs index c5ed28319b1..bc17ea97cca 100644 --- a/scripts/ci-changed-scope.mjs +++ b/scripts/ci-changed-scope.mjs @@ -4,7 +4,7 @@ import { appendFileSync } from "node:fs"; /** @typedef {{ runNode: boolean; runMacos: boolean; runAndroid: boolean; runWindows: boolean; runSkillsPython: boolean }} ChangedScope */ const DOCS_PATH_RE = /^(docs\/|.*\.mdx?$)/; -const SKILLS_PYTHON_SCOPE_RE = /^skills\//; +const SKILLS_PYTHON_SCOPE_RE = /^(skills\/|pyproject\.toml$)/; const CI_WORKFLOW_SCOPE_RE = /^\.github\/workflows\/ci\.yml$/; const MACOS_PROTOCOL_GEN_RE = /^(apps\/macos\/Sources\/OpenClawProtocol\/|apps\/shared\/OpenClawKit\/Sources\/OpenClawProtocol\/)/; diff --git a/scripts/test-parallel-memory.mjs b/scripts/test-parallel-memory.mjs index b036fc22fa6..3bf9eca4049 100644 --- a/scripts/test-parallel-memory.mjs +++ b/scripts/test-parallel-memory.mjs @@ -7,9 +7,14 @@ const ANSI_ESCAPE_PATTERN = new RegExp( `${ESCAPE}(?:\\][^${BELL}]*(?:${BELL}|${ESCAPE}\\\\)|\\[[0-?]*[ -/]*[@-~]|[@-Z\\\\-_])`, "g", ); +const GITHUB_ACTIONS_LOG_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s+/u; const COMPLETED_TEST_FILE_LINE_PATTERN = /(?(?:src|extensions|test|ui)\/\S+?\.(?:live\.test|e2e\.test|test)\.ts)\s+\(.*\)\s+(?\d+(?:\.\d+)?)(?ms|s)\s*$/; +const MEMORY_TRACE_SUMMARY_PATTERN = + /^\[test-parallel\]\[mem\] summary (?\S+) files=(?\d+) peak=(?[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB)) totalDelta=(?[+-]?[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB)) peakAt=(?\S+) top=(?.*)$/u; +const MEMORY_TRACE_TOP_ENTRY_PATTERN = + /^(?(?:src|extensions|test|ui)\/\S+?\.(?:live\.test|e2e\.test|test)\.ts):(?[+-]?[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB))$/u; const PS_COLUMNS = ["pid=", "ppid=", "rss=", "comm="]; @@ -21,13 +26,33 @@ function parseDurationMs(rawValue, unit) { return unit === "s" ? Math.round(parsed * 1000) : Math.round(parsed); } +export function parseMemoryValueKb(rawValue) { + const match = rawValue.match(/^(?[+-]?)(?\d+(?:\.\d+)?)(?GiB|MiB|KiB)$/u); + if (!match?.groups) { + return null; + } + const value = Number.parseFloat(match.groups.value); + if (!Number.isFinite(value)) { + return null; + } + const multiplier = + match.groups.unit === "GiB" ? 1024 ** 2 : match.groups.unit === "MiB" ? 1024 : 1; + const signed = Math.round(value * multiplier); + return match.groups.sign === "-" ? -signed : signed; +} + function stripAnsi(text) { return text.replaceAll(ANSI_ESCAPE_PATTERN, ""); } +function normalizeLogLine(line) { + return line.replace(GITHUB_ACTIONS_LOG_PREFIX_PATTERN, ""); +} + export function parseCompletedTestFileLines(text) { return stripAnsi(text) .split(/\r?\n/u) + .map((line) => normalizeLogLine(line)) .map((line) => { const match = line.match(COMPLETED_TEST_FILE_LINE_PATTERN); if (!match?.groups) { @@ -41,6 +66,53 @@ export function parseCompletedTestFileLines(text) { .filter((entry) => entry !== null); } +export function parseMemoryTraceSummaryLines(text) { + return stripAnsi(text) + .split(/\r?\n/u) + .map((line) => normalizeLogLine(line)) + .map((line) => { + const match = line.match(MEMORY_TRACE_SUMMARY_PATTERN); + if (!match?.groups) { + return null; + } + const peakRssKb = parseMemoryValueKb(match.groups.peak); + const totalDeltaKb = parseMemoryValueKb(match.groups.totalDelta); + const fileCount = Number.parseInt(match.groups.files, 10); + if (!Number.isInteger(fileCount) || peakRssKb === null || totalDeltaKb === null) { + return null; + } + const top = + match.groups.top === "none" + ? [] + : match.groups.top + .split(/,\s+/u) + .map((entry) => { + const topMatch = entry.match(MEMORY_TRACE_TOP_ENTRY_PATTERN); + if (!topMatch?.groups) { + return null; + } + const deltaKb = parseMemoryValueKb(topMatch.groups.delta); + if (deltaKb === null) { + return null; + } + return { + file: topMatch.groups.file, + deltaKb, + }; + }) + .filter((entry) => entry !== null); + return { + lane: match.groups.lane, + files: fileCount, + peakRssKb, + totalDeltaKb, + peakAt: match.groups.peakAt, + top, + }; + }) + .filter((entry) => entry !== null); +} + export function getProcessTreeRecords(rootPid) { if (!Number.isInteger(rootPid) || rootPid <= 0 || process.platform === "win32") { return null; diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 38dea1b2ead..011211a307b 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -15,10 +15,11 @@ import { resolveTestRunExitCode, } from "./test-parallel-utils.mjs"; import { + loadUnitMemoryHotspotManifest, loadTestRunnerBehavior, loadUnitTimingManifest, + selectUnitHeavyFileGroups, packFilesByDuration, - selectTimedHeavyFiles, } from "./test-runner-manifest.mjs"; // On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell @@ -27,6 +28,25 @@ const pnpm = "pnpm"; const behaviorManifest = loadTestRunnerBehavior(); const existingFiles = (entries) => entries.map((entry) => entry.file).filter((file) => fs.existsSync(file)); +let tempArtifactDir = null; +const ensureTempArtifactDir = () => { + if (tempArtifactDir === null) { + tempArtifactDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-test-parallel-")); + } + return tempArtifactDir; +}; +const writeTempJsonArtifact = (name, value) => { + const filePath = path.join(ensureTempArtifactDir(), `${name}.json`); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); + return filePath; +}; +const cleanupTempArtifacts = () => { + if (tempArtifactDir === null) { + return; + } + fs.rmSync(tempArtifactDir, { recursive: true, force: true }); + tempArtifactDir = null; +}; const existingUnitConfigFiles = (entries) => existingFiles(entries).filter(isUnitConfigTestFile); const unitBehaviorIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.isolated); const unitSingletonIsolatedFiles = existingUnitConfigFiles(behaviorManifest.unit.singletonIsolated); @@ -262,6 +282,7 @@ const inferTarget = (fileFilter) => { return { owner: "base", isolated }; }; const unitTimingManifest = loadUnitTimingManifest(); +const unitMemoryHotspotManifest = loadUnitMemoryHotspotManifest(); const parseEnvNumber = (name, fallback) => { const parsed = Number.parseInt(process.env[name] ?? "", 10); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; @@ -298,21 +319,78 @@ const heavyUnitLaneCount = parseEnvNumber( defaultHeavyUnitLaneCount, ); const heavyUnitMinDurationMs = parseEnvNumber("OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200); -const timedHeavyUnitFiles = - shouldSplitUnitRuns && heavyUnitFileLimit > 0 - ? selectTimedHeavyFiles({ +const defaultMemoryHeavyUnitFileLimit = + testProfile === "serial" ? 0 : isCI ? 64 : testProfile === "low" ? 8 : 16; +const memoryHeavyUnitFileLimit = parseEnvNumber( + "OPENCLAW_TEST_MEMORY_HEAVY_UNIT_FILE_LIMIT", + defaultMemoryHeavyUnitFileLimit, +); +const memoryHeavyUnitMinDeltaKb = parseEnvNumber( + "OPENCLAW_TEST_MEMORY_HEAVY_UNIT_MIN_KB", + unitMemoryHotspotManifest.defaultMinDeltaKb, +); +const { memoryHeavyFiles: memoryHeavyUnitFiles, timedHeavyFiles: timedHeavyUnitFiles } = + shouldSplitUnitRuns + ? selectUnitHeavyFileGroups({ candidates: allKnownUnitFiles, - limit: heavyUnitFileLimit, - minDurationMs: heavyUnitMinDurationMs, - exclude: unitBehaviorOverrideSet, + behaviorOverrides: unitBehaviorOverrideSet, + timedLimit: heavyUnitFileLimit, + timedMinDurationMs: heavyUnitMinDurationMs, + memoryLimit: memoryHeavyUnitFileLimit, + memoryMinDeltaKb: memoryHeavyUnitMinDeltaKb, timings: unitTimingManifest, + hotspots: unitMemoryHotspotManifest, }) - : []; + : { + memoryHeavyFiles: [], + timedHeavyFiles: [], + }; +const unitSchedulingOverrideSet = new Set([...unitBehaviorOverrideSet, ...memoryHeavyUnitFiles]); const unitFastExcludedFiles = [ - ...new Set([...unitBehaviorOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]), + ...new Set([...unitSchedulingOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]), +]; +const unitAutoSingletonFiles = [ + ...new Set([...unitSingletonIsolatedFiles, ...memoryHeavyUnitFiles]), ]; const estimateUnitDurationMs = (file) => unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; +const unitFastExcludedFileSet = new Set(unitFastExcludedFiles); +const unitFastCandidateFiles = allKnownUnitFiles.filter( + (file) => !unitFastExcludedFileSet.has(file), +); +const defaultUnitFastLaneCount = isCI && !isWindows ? 3 : 1; +const unitFastLaneCount = Math.max( + 1, + parseEnvNumber("OPENCLAW_TEST_UNIT_FAST_LANES", defaultUnitFastLaneCount), +); +// Heap snapshots on current main show long-lived unit-fast workers retaining +// transformed Vitest/Vite module graphs rather than app objects. Multiple +// bounded unit-fast lanes only help if we also recycle them serially instead +// of keeping several transform-heavy workers resident at the same time. +const unitFastBuckets = + unitFastLaneCount > 1 + ? packFilesByDuration(unitFastCandidateFiles, unitFastLaneCount, estimateUnitDurationMs) + : [unitFastCandidateFiles]; +const unitFastEntries = unitFastBuckets + .filter((files) => files.length > 0) + .map((files, index) => ({ + name: unitFastBuckets.length === 1 ? "unit-fast" : `unit-fast-${String(index + 1)}`, + serialPhase: "unit-fast", + env: { + OPENCLAW_VITEST_INCLUDE_FILE: writeTempJsonArtifact( + `vitest-unit-fast-include-${String(index + 1)}`, + files, + ), + }, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ], + })); const heavyUnitBuckets = packFilesByDuration( timedHeavyUnitFiles, heavyUnitLaneCount, @@ -325,18 +403,7 @@ const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({ const baseRuns = [ ...(shouldSplitUnitRuns ? [ - { - name: "unit-fast", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - ...unitFastExcludedFiles.flatMap((file) => ["--exclude", file]), - ], - }, + ...unitFastEntries, ...(unitBehaviorIsolatedFiles.length > 0 ? [ { @@ -353,7 +420,7 @@ const baseRuns = [ ] : []), ...unitHeavyEntries, - ...unitSingletonIsolatedFiles.map((file) => ({ + ...unitAutoSingletonFiles.map((file) => ({ name: `${path.basename(file, ".test.ts")}-isolated`, args: [ "vitest", @@ -616,6 +683,8 @@ const keepGatewaySerial = !parallelGatewayEnabled; const parallelRuns = keepGatewaySerial ? runs.filter((entry) => entry.name !== "gateway") : runs; const serialRuns = keepGatewaySerial ? runs.filter((entry) => entry.name === "gateway") : []; +const serialPrefixRuns = parallelRuns.filter((entry) => entry.serialPhase); +const deferredParallelRuns = parallelRuns.filter((entry) => !entry.serialPhase); const baseLocalWorkers = Math.max(4, Math.min(16, hostCpuCount)); const loadAwareDisabledRaw = process.env.OPENCLAW_TEST_LOAD_AWARE?.trim().toLowerCase(); const loadAwareDisabled = loadAwareDisabledRaw === "0" || loadAwareDisabledRaw === "false"; @@ -960,7 +1029,12 @@ const runOnce = (entry, extraArgs = []) => try { child = spawn(pnpm, args, { stdio: ["inherit", "pipe", "pipe"], - env: { ...process.env, VITEST_GROUP: entry.name, NODE_OPTIONS: resolvedNodeOptions }, + env: { + ...process.env, + ...entry.env, + VITEST_GROUP: entry.name, + NODE_OPTIONS: resolvedNodeOptions, + }, shell: isWindows, }); captureTreeSample("spawn"); @@ -1112,6 +1186,7 @@ const shutdown = (signal) => { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("exit", cleanupTempArtifacts); if (process.env.OPENCLAW_TEST_LIST_LANES === "1") { const entriesToPrint = targetedEntries.length > 0 ? targetedEntries : runs; @@ -1166,15 +1241,29 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) { process.exit(2); } -if (isMacMiniProfile && targetedEntries.length === 0) { - const unitFastEntry = parallelRuns.find((entry) => entry.name === "unit-fast"); - if (unitFastEntry) { - const unitFastCode = await run(unitFastEntry, passthroughOptionArgs); +if (serialPrefixRuns.length > 0) { + const failedSerialPrefix = await runEntriesWithLimit(serialPrefixRuns, passthroughOptionArgs, 1); + if (failedSerialPrefix !== undefined) { + process.exit(failedSerialPrefix); + } + const failedDeferredParallel = isMacMiniProfile + ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, 3) + : await runEntries(deferredParallelRuns, passthroughOptionArgs); + if (failedDeferredParallel !== undefined) { + process.exit(failedDeferredParallel); + } +} else if (isMacMiniProfile && targetedEntries.length === 0) { + const unitFastEntriesForMacMini = parallelRuns.filter((entry) => + entry.name.startsWith("unit-fast"), + ); + for (const entry of unitFastEntriesForMacMini) { + // eslint-disable-next-line no-await-in-loop + const unitFastCode = await run(entry, passthroughOptionArgs); if (unitFastCode !== 0) { process.exit(unitFastCode); } } - const deferredEntries = parallelRuns.filter((entry) => entry.name !== "unit-fast"); + const deferredEntries = parallelRuns.filter((entry) => !entry.name.startsWith("unit-fast")); const failedMacMiniParallel = await runEntriesWithLimit( deferredEntries, passthroughOptionArgs, diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs index 30b4414acc7..4e0ff9d0a5a 100644 --- a/scripts/test-runner-manifest.mjs +++ b/scripts/test-runner-manifest.mjs @@ -3,12 +3,18 @@ import path from "node:path"; export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json"; export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json"; +export const unitMemoryHotspotManifestPath = "test/fixtures/test-memory-hotspots.unit.json"; const defaultTimingManifest = { config: "vitest.unit.config.ts", defaultDurationMs: 250, files: {}, }; +const defaultMemoryHotspotManifest = { + config: "vitest.unit.config.ts", + defaultMinDeltaKb: 256 * 1024, + files: {}, +}; const readJson = (filePath, fallback) => { try { @@ -82,6 +88,46 @@ export function loadUnitTimingManifest() { }; } +export function loadUnitMemoryHotspotManifest() { + const raw = readJson(unitMemoryHotspotManifestPath, defaultMemoryHotspotManifest); + const defaultMinDeltaKb = + Number.isFinite(raw.defaultMinDeltaKb) && raw.defaultMinDeltaKb > 0 + ? raw.defaultMinDeltaKb + : defaultMemoryHotspotManifest.defaultMinDeltaKb; + const files = Object.fromEntries( + Object.entries(raw.files ?? {}) + .map(([file, value]) => { + const normalizedFile = normalizeRepoPath(file); + const deltaKb = + Number.isFinite(value?.deltaKb) && value.deltaKb > 0 ? Math.round(value.deltaKb) : null; + const sources = Array.isArray(value?.sources) + ? value.sources.filter((source) => typeof source === "string" && source.length > 0) + : []; + if (deltaKb === null) { + return [normalizedFile, null]; + } + return [ + normalizedFile, + { + deltaKb, + ...(sources.length > 0 ? { sources } : {}), + }, + ]; + }) + .filter(([, value]) => value !== null), + ); + + return { + config: + typeof raw.config === "string" && raw.config + ? raw.config + : defaultMemoryHotspotManifest.config, + generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "", + defaultMinDeltaKb, + files, + }; +} + export function selectTimedHeavyFiles({ candidates, limit, @@ -102,6 +148,64 @@ export function selectTimedHeavyFiles({ .map((entry) => entry.file); } +export function selectMemoryHeavyFiles({ + candidates, + limit, + minDeltaKb, + exclude = new Set(), + hotspots, +}) { + return candidates + .filter((file) => !exclude.has(file)) + .map((file) => ({ + file, + deltaKb: hotspots.files[file]?.deltaKb ?? 0, + known: Boolean(hotspots.files[file]), + })) + .filter((entry) => entry.known && entry.deltaKb >= minDeltaKb) + .toSorted((a, b) => b.deltaKb - a.deltaKb) + .slice(0, limit) + .map((entry) => entry.file); +} + +export function selectUnitHeavyFileGroups({ + candidates, + behaviorOverrides = new Set(), + timedLimit, + timedMinDurationMs, + memoryLimit, + memoryMinDeltaKb, + timings, + hotspots, +}) { + const memoryHeavyFiles = + memoryLimit > 0 + ? selectMemoryHeavyFiles({ + candidates, + limit: memoryLimit, + minDeltaKb: memoryMinDeltaKb, + exclude: behaviorOverrides, + hotspots, + }) + : []; + const schedulingOverrides = new Set([...behaviorOverrides, ...memoryHeavyFiles]); + const timedHeavyFiles = + timedLimit > 0 + ? selectTimedHeavyFiles({ + candidates, + limit: timedLimit, + minDurationMs: timedMinDurationMs, + exclude: schedulingOverrides, + timings, + }) + : []; + + return { + memoryHeavyFiles, + timedHeavyFiles, + }; +} + export function packFilesByDuration(files, bucketCount, estimateDurationMs) { const normalizedBucketCount = Math.max(0, Math.floor(bucketCount)); if (normalizedBucketCount <= 0 || files.length === 0) { diff --git a/scripts/test-update-memory-hotspots.mjs b/scripts/test-update-memory-hotspots.mjs new file mode 100644 index 00000000000..2abbf2b2d02 --- /dev/null +++ b/scripts/test-update-memory-hotspots.mjs @@ -0,0 +1,152 @@ +import fs from "node:fs"; +import path from "node:path"; +import { parseMemoryTraceSummaryLines } from "./test-parallel-memory.mjs"; +import { unitMemoryHotspotManifestPath } from "./test-runner-manifest.mjs"; + +function parseArgs(argv) { + const args = { + config: "vitest.unit.config.ts", + out: unitMemoryHotspotManifestPath, + lane: "unit-fast", + logs: [], + minDeltaKb: 256 * 1024, + limit: 64, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--config") { + args.config = argv[i + 1] ?? args.config; + i += 1; + continue; + } + if (arg === "--out") { + args.out = argv[i + 1] ?? args.out; + i += 1; + continue; + } + if (arg === "--lane") { + args.lane = argv[i + 1] ?? args.lane; + i += 1; + continue; + } + if (arg === "--log") { + const logPath = argv[i + 1]; + if (typeof logPath === "string" && logPath.length > 0) { + args.logs.push(logPath); + } + i += 1; + continue; + } + if (arg === "--min-delta-kb") { + const parsed = Number.parseInt(argv[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) { + args.minDeltaKb = parsed; + } + i += 1; + continue; + } + if (arg === "--limit") { + const parsed = Number.parseInt(argv[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) { + args.limit = parsed; + } + i += 1; + continue; + } + } + return args; +} + +function mergeHotspotEntry(aggregated, file, value) { + if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) { + return; + } + const normalizeSourceLabel = (source) => { + const separator = source.lastIndexOf(":"); + if (separator === -1) { + return source.endsWith(".log") ? source.slice(0, -4) : source; + } + const name = source.slice(0, separator); + const lane = source.slice(separator + 1); + return `${name.endsWith(".log") ? name.slice(0, -4) : name}:${lane}`; + }; + const nextSources = Array.isArray(value?.sources) + ? value.sources + .filter((source) => typeof source === "string" && source.length > 0) + .map(normalizeSourceLabel) + : []; + const previous = aggregated.get(file); + if (!previous) { + aggregated.set(file, { + deltaKb: Math.round(value.deltaKb), + sources: [...new Set(nextSources)], + }); + return; + } + previous.deltaKb = Math.max(previous.deltaKb, Math.round(value.deltaKb)); + for (const source of nextSources) { + if (!previous.sources.includes(source)) { + previous.sources.push(source); + } + } +} + +const opts = parseArgs(process.argv.slice(2)); + +if (opts.logs.length === 0) { + console.error("[test-update-memory-hotspots] pass at least one --log ."); + process.exit(2); +} + +const aggregated = new Map(); +try { + const existing = JSON.parse(fs.readFileSync(opts.out, "utf8")); + for (const [file, value] of Object.entries(existing.files ?? {})) { + mergeHotspotEntry(aggregated, file, value); + } +} catch { + // Start from scratch when the output file does not exist yet. +} +for (const logPath of opts.logs) { + const text = fs.readFileSync(logPath, "utf8"); + const summaries = parseMemoryTraceSummaryLines(text).filter( + (summary) => summary.lane === opts.lane, + ); + for (const summary of summaries) { + for (const record of summary.top) { + if (record.deltaKb < opts.minDeltaKb) { + continue; + } + mergeHotspotEntry(aggregated, record.file, { + deltaKb: record.deltaKb, + sources: [`${path.basename(logPath, path.extname(logPath))}:${summary.lane}`], + }); + } + } +} + +const files = Object.fromEntries( + [...aggregated.entries()] + .toSorted((left, right) => right[1].deltaKb - left[1].deltaKb) + .slice(0, opts.limit) + .map(([file, value]) => [ + file, + { + deltaKb: value.deltaKb, + sources: value.sources.toSorted(), + }, + ]), +); + +const output = { + config: opts.config, + generatedAt: new Date().toISOString(), + defaultMinDeltaKb: opts.minDeltaKb, + lane: opts.lane, + files, +}; + +fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`); +console.log( + `[test-update-memory-hotspots] wrote ${String(Object.keys(files).length)} hotspots to ${opts.out}`, +); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 605cdd22118..2fec27a45e2 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -1,6 +1,17 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { OpenClawConfig } from "../../config/config.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + extractLeadingHttpStatus, + formatRawAssistantErrorForUi, + isCloudflareOrHtmlErrorPage, +} from "../../shared/assistant-error-format.js"; +export { + extractLeadingHttpStatus, + formatRawAssistantErrorForUi, + isCloudflareOrHtmlErrorPage, + parseApiErrorInfo, +} from "../../shared/assistant-error-format.js"; import { formatSandboxToolPolicyBlockedMessage } from "../sandbox.js"; import { stableStringify } from "../stable-stringify.js"; import { @@ -220,10 +231,6 @@ const ERROR_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; const CONTEXT_OVERFLOW_ERROR_HEAD_RE = /^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i; -const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; -const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; -const HTML_ERROR_PREFIX_RE = /^\s*(?:/i.test(status.rest) - ); -} - export function isTransientHttpError(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { @@ -484,15 +459,14 @@ function isLikelyHttpErrorText(raw: string): boolean { if (isCloudflareOrHtmlErrorPage(raw)) { return true; } - const match = raw.match(HTTP_STATUS_PREFIX_RE); - if (!match) { + const status = extractLeadingHttpStatus(raw); + if (!status) { return false; } - const code = Number(match[1]); - if (!Number.isFinite(code) || code < 400) { + if (status.code < 400) { return false; } - const message = match[2].toLowerCase(); + const message = status.rest.toLowerCase(); return HTTP_ERROR_HINTS.some((hint) => message.includes(hint)); } @@ -580,99 +554,6 @@ export function isRawApiErrorPayload(raw?: string): boolean { return getApiErrorPayloadFingerprint(raw) !== null; } -export type ApiErrorInfo = { - httpCode?: string; - type?: string; - message?: string; - requestId?: string; -}; - -export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null { - if (!raw) { - return null; - } - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - - let httpCode: string | undefined; - let candidate = trimmed; - - const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); - if (httpPrefixMatch) { - httpCode = httpPrefixMatch[1]; - candidate = httpPrefixMatch[2].trim(); - } - - const payload = parseApiErrorPayload(candidate); - if (!payload) { - return null; - } - - const requestId = - typeof payload.request_id === "string" - ? payload.request_id - : typeof payload.requestId === "string" - ? payload.requestId - : undefined; - - const topType = typeof payload.type === "string" ? payload.type : undefined; - const topMessage = typeof payload.message === "string" ? payload.message : undefined; - - let errType: string | undefined; - let errMessage: string | undefined; - if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { - const err = payload.error as Record; - if (typeof err.type === "string") { - errType = err.type; - } - if (typeof err.code === "string" && !errType) { - errType = err.code; - } - if (typeof err.message === "string") { - errMessage = err.message; - } - } - - return { - httpCode, - type: errType ?? topType, - message: errMessage ?? topMessage, - requestId, - }; -} - -export function formatRawAssistantErrorForUi(raw?: string): string { - const trimmed = (raw ?? "").trim(); - if (!trimmed) { - return "LLM request failed with an unknown error."; - } - - const leadingStatus = extractLeadingHttpStatus(trimmed); - if (leadingStatus && isCloudflareOrHtmlErrorPage(trimmed)) { - return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`; - } - - const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE); - if (httpMatch) { - const rest = httpMatch[2].trim(); - if (!rest.startsWith("{")) { - return `HTTP ${httpMatch[1]}: ${rest}`; - } - } - - const info = parseApiErrorInfo(trimmed); - if (info?.message) { - const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; - const type = info.type ? ` ${info.type}` : ""; - const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; - return `${prefix}${type}: ${info.message}${requestId}`; - } - - return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; -} - export function formatAssistantErrorText( msg: AssistantMessage, opts?: { cfg?: OpenClawConfig; sessionKey?: string; provider?: string; model?: string }, diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index 022054c5416..f69876ed04a 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -21,6 +21,13 @@ export type SearchConfigRecord = (NonNullable["web"] ex : never) & Record; +type UnsupportedWebSearchFilterName = + | "country" + | "language" + | "freshness" + | "date_after" + | "date_before"; + export const DEFAULT_SEARCH_COUNT = 5; export const MAX_SEARCH_COUNT = 10; @@ -210,3 +217,59 @@ export function writeCachedSearchPayload( ): void { writeCache(SEARCH_CACHE, cacheKey, payload, ttlMs); } + +function readUnsupportedSearchFilter( + params: Record, +): UnsupportedWebSearchFilterName | undefined { + for (const name of ["country", "language", "freshness", "date_after", "date_before"] as const) { + const value = params[name]; + if (typeof value === "string" && value.trim()) { + return name; + } + } + + return undefined; +} + +function describeUnsupportedSearchFilter(name: UnsupportedWebSearchFilterName): string { + switch (name) { + case "country": + return "country filtering"; + case "language": + return "language filtering"; + case "freshness": + return "freshness filtering"; + case "date_after": + case "date_before": + return "date_after/date_before filtering"; + } +} + +export function buildUnsupportedSearchFilterResponse( + params: Record, + provider: string, + docs = "https://docs.openclaw.ai/tools/web", +): + | { + error: string; + message: string; + docs: string; + } + | undefined { + const unsupported = readUnsupportedSearchFilter(params); + if (!unsupported) { + return undefined; + } + + const label = describeUnsupportedSearchFilter(unsupported); + const supportedLabel = + unsupported === "date_after" || unsupported === "date_before" ? "date filtering" : label; + + return { + error: unsupported.startsWith("date_") + ? "unsupported_date_filter" + : `unsupported_${unsupported}`, + message: `${label} is not supported by the ${provider} provider. Only Brave and Perplexity support ${supportedLabel}.`, + docs, + }; +} diff --git a/src/agents/tools/web-search-provider-config.ts b/src/agents/tools/web-search-provider-config.ts index 3e246b93068..dd938957b12 100644 --- a/src/agents/tools/web-search-provider-config.ts +++ b/src/agents/tools/web-search-provider-config.ts @@ -71,6 +71,37 @@ export function setScopedCredentialValue( (scoped as Record).apiKey = value; } +export function mergeScopedSearchConfig( + searchConfig: Record | undefined, + key: string, + pluginConfig: Record | undefined, + options?: { mirrorApiKeyToTopLevel?: boolean }, +): Record | undefined { + if (!pluginConfig) { + return searchConfig; + } + + const currentScoped = + searchConfig?.[key] && + typeof searchConfig[key] === "object" && + !Array.isArray(searchConfig[key]) + ? (searchConfig[key] as Record) + : {}; + const next: Record = { + ...searchConfig, + [key]: { + ...currentScoped, + ...pluginConfig, + }, + }; + + if (options?.mirrorApiKeyToTopLevel && pluginConfig.apiKey !== undefined) { + next.apiKey = pluginConfig.apiKey; + } + + return next; +} + export function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { const search = cfg?.tools?.web?.search; if (!search || typeof search !== "object") { diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 54242f362f0..9f3a6fe017c 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -3,6 +3,10 @@ import { __testing as braveTesting } from "../../../extensions/brave/src/brave-w import { __testing as moonshotTesting } from "../../../extensions/moonshot/src/kimi-web-search-provider.js"; import { __testing as perplexityTesting } from "../../../extensions/perplexity/web-search-provider.js"; import { __testing as xaiTesting } from "../../../extensions/xai/src/grok-web-search-provider.js"; +import { + buildUnsupportedSearchFilterResponse, + mergeScopedSearchConfig, +} from "../../plugin-sdk/provider-web-search.js"; import { withEnv } from "../../test-utils/env.js"; const { inferPerplexityBaseUrlFromApiKey, @@ -198,6 +202,64 @@ describe("web_search date normalization", () => { }); }); +describe("web_search unsupported filter response", () => { + it("returns undefined when no unsupported filter is set", () => { + expect(buildUnsupportedSearchFilterResponse({ query: "openclaw" }, "gemini")).toBeUndefined(); + }); + + it("maps non-date filters to provider-specific unsupported errors", () => { + expect(buildUnsupportedSearchFilterResponse({ country: "us" }, "grok")).toEqual({ + error: "unsupported_country", + message: + "country filtering is not supported by the grok provider. Only Brave and Perplexity support country filtering.", + docs: "https://docs.openclaw.ai/tools/web", + }); + }); + + it("collapses date filters to unsupported_date_filter", () => { + expect(buildUnsupportedSearchFilterResponse({ date_before: "2026-03-19" }, "kimi")).toEqual({ + error: "unsupported_date_filter", + message: + "date_after/date_before filtering is not supported by the kimi provider. Only Brave and Perplexity support date filtering.", + docs: "https://docs.openclaw.ai/tools/web", + }); + }); +}); + +describe("web_search scoped config merge", () => { + it("returns the original config when no plugin config exists", () => { + const searchConfig = { provider: "grok", grok: { model: "grok-4-1-fast" } }; + expect(mergeScopedSearchConfig(searchConfig, "grok", undefined)).toBe(searchConfig); + }); + + it("merges plugin config into the scoped provider object", () => { + expect( + mergeScopedSearchConfig({ provider: "grok", grok: { model: "old-model" } }, "grok", { + model: "new-model", + apiKey: "xai-test-key", + }), + ).toEqual({ + provider: "grok", + grok: { model: "new-model", apiKey: "xai-test-key" }, + }); + }); + + it("can mirror the plugin apiKey to the top level config", () => { + expect( + mergeScopedSearchConfig( + { provider: "brave", brave: { count: 5 } }, + "brave", + { apiKey: "brave-test-key" }, + { mirrorApiKeyToTopLevel: true }, + ), + ).toEqual({ + provider: "brave", + apiKey: "brave-test-key", + brave: { count: 5, apiKey: "brave-test-key" }, + }); + }); +}); + describe("web_search kimi config resolution", () => { it("uses config apiKey when provided", () => { expect(resolveKimiApiKey({ apiKey: "kimi-test-key" })).toBe("kimi-test-key"); diff --git a/src/channels/plugins/contracts/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts index efc85cb74b4..87f7922c3e4 100644 --- a/src/channels/plugins/contracts/session-binding.contract.test.ts +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -22,12 +22,12 @@ vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => { }; }); -beforeEach(() => { +beforeEach(async () => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); discordThreadBindingTesting.resetThreadBindingsForTests(); feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); resetMatrixThreadBindingsForTests(); - telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); + await telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); }); for (const entry of sessionBindingContractRegistry) { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index b8ec52ca171..9acdb601e10 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -394,4 +394,279 @@ describe("normalizeCompatibilityConfigValues", () => { expect(res.config.skills?.allowBundled).toEqual(["peekaboo"]); expect(res.changes).toEqual(["Removed nano-banana-pro from skills.allowBundled."]); }); + + it("migrates legacy web search provider config to plugin-owned config paths", () => { + const res = normalizeCompatibilityConfigValues({ + tools: { + web: { + search: { + provider: "gemini", + maxResults: 5, + apiKey: "brave-key", + gemini: { + apiKey: "gemini-key", + model: "gemini-2.5-flash", + }, + firecrawl: { + apiKey: "firecrawl-key", + baseUrl: "https://api.firecrawl.dev", + }, + }, + }, + }, + }); + + expect(res.config.tools?.web?.search).toEqual({ + provider: "gemini", + maxResults: 5, + }); + expect(res.config.plugins?.entries?.brave).toEqual({ + enabled: true, + config: { + webSearch: { + apiKey: "brave-key", + }, + }, + }); + expect(res.config.plugins?.entries?.google).toEqual({ + enabled: true, + config: { + webSearch: { + apiKey: "gemini-key", + model: "gemini-2.5-flash", + }, + }, + }); + expect(res.config.plugins?.entries?.firecrawl).toEqual({ + enabled: true, + config: { + webSearch: { + apiKey: "firecrawl-key", + baseUrl: "https://api.firecrawl.dev", + }, + }, + }); + expect(res.changes).toEqual([ + "Moved tools.web.search.apiKey → plugins.entries.brave.config.webSearch.apiKey.", + "Moved tools.web.search.firecrawl → plugins.entries.firecrawl.config.webSearch.", + "Moved tools.web.search.gemini → plugins.entries.google.config.webSearch.", + ]); + }); + + it("merges legacy web search provider config into explicit plugin config without overriding it", () => { + const res = normalizeCompatibilityConfigValues({ + tools: { + web: { + search: { + provider: "gemini", + gemini: { + apiKey: "legacy-gemini-key", + model: "legacy-model", + }, + }, + }, + }, + plugins: { + entries: { + google: { + enabled: true, + config: { + webSearch: { + model: "explicit-model", + baseUrl: "https://generativelanguage.googleapis.com", + }, + }, + }, + }, + }, + }); + + expect(res.config.tools?.web?.search).toEqual({ + provider: "gemini", + }); + expect(res.config.plugins?.entries?.google).toEqual({ + enabled: true, + config: { + webSearch: { + apiKey: "legacy-gemini-key", + model: "explicit-model", + baseUrl: "https://generativelanguage.googleapis.com", + }, + }, + }); + expect(res.changes).toEqual([ + "Merged tools.web.search.gemini → plugins.entries.google.config.webSearch (filled missing fields from legacy; kept explicit plugin config values).", + ]); + }); + + it("migrates legacy talk flat fields to provider/providers", () => { + const res = normalizeCompatibilityConfigValues({ + talk: { + voiceId: "voice-123", + voiceAliases: { + Clawd: "EXAVITQu4vr4xnSDxMaL", + }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + interruptOnSpeech: false, + silenceTimeoutMs: 1500, + }, + }); + + expect(res.config.talk).toEqual({ + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-123", + voiceAliases: { + Clawd: "EXAVITQu4vr4xnSDxMaL", + }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + }, + }, + voiceId: "voice-123", + voiceAliases: { + Clawd: "EXAVITQu4vr4xnSDxMaL", + }, + modelId: "eleven_v3", + outputFormat: "pcm_44100", + apiKey: "secret-key", + interruptOnSpeech: false, + silenceTimeoutMs: 1500, + }); + expect(res.changes).toEqual([ + "Moved legacy talk flat fields → talk.provider/talk.providers.elevenlabs.", + ]); + }); + + it("normalizes talk provider ids without overriding explicit provider config", () => { + const res = normalizeCompatibilityConfigValues({ + talk: { + provider: " elevenlabs ", + providers: { + " elevenlabs ": { + voiceId: "voice-123", + }, + }, + apiKey: "secret-key", + }, + }); + + expect(res.config.talk).toEqual({ + provider: "elevenlabs", + providers: { + elevenlabs: { + voiceId: "voice-123", + }, + }, + apiKey: "secret-key", + }); + expect(res.changes).toEqual([ + "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", + ]); + }); + + it("migrates tools.message.allowCrossContextSend to canonical crossContext settings", () => { + const res = normalizeCompatibilityConfigValues({ + tools: { + message: { + allowCrossContextSend: true, + crossContext: { + allowWithinProvider: false, + allowAcrossProviders: false, + }, + }, + }, + }); + + expect(res.config.tools?.message).toEqual({ + crossContext: { + allowWithinProvider: true, + allowAcrossProviders: true, + }, + }); + expect(res.changes).toEqual([ + "Moved tools.message.allowCrossContextSend → tools.message.crossContext.allowWithinProvider/allowAcrossProviders (true).", + ]); + }); + + it("migrates legacy deepgram media options to providerOptions.deepgram", () => { + const res = normalizeCompatibilityConfigValues({ + tools: { + media: { + audio: { + deepgram: { + detectLanguage: true, + smartFormat: true, + }, + providerOptions: { + deepgram: { + punctuate: false, + }, + }, + models: [ + { + provider: "deepgram", + deepgram: { + punctuate: true, + }, + }, + ], + }, + models: [ + { + provider: "deepgram", + deepgram: { + smartFormat: false, + }, + providerOptions: { + deepgram: { + detect_language: true, + }, + }, + }, + ], + }, + }, + }); + + expect(res.config.tools?.media?.audio).toEqual({ + providerOptions: { + deepgram: { + detect_language: true, + smart_format: true, + punctuate: false, + }, + }, + models: [ + { + provider: "deepgram", + providerOptions: { + deepgram: { + punctuate: true, + }, + }, + }, + ], + }); + expect(res.config.tools?.media?.models).toEqual([ + { + provider: "deepgram", + providerOptions: { + deepgram: { + smart_format: false, + detect_language: true, + }, + }, + }, + ]); + expect(res.changes).toEqual([ + "Merged tools.media.audio.deepgram → tools.media.audio.providerOptions.deepgram (filled missing canonical fields from legacy).", + "Moved tools.media.audio.models[0].deepgram → tools.media.audio.models[0].providerOptions.deepgram.", + "Merged tools.media.models[0].deepgram → tools.media.models[0].providerOptions.deepgram (filled missing canonical fields from legacy).", + ]); + }); }); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index c3376bd74e9..36c86bc0315 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -8,6 +8,8 @@ import { resolveSlackStreamingMode, resolveTelegramPreviewStreamMode, } from "../config/discord-preview-streaming.js"; +import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js"; +import { DEFAULT_TALK_PROVIDER, normalizeTalkSection } from "../config/talk.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { @@ -429,6 +431,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { normalizeProvider("discord"); seedMissingDefaultAccountsFromSingleAccountBase(); normalizeLegacyBrowserProfiles(); + const webSearchMigration = migrateLegacyWebSearchConfig(next); + if (webSearchMigration.changes.length > 0) { + next = webSearchMigration.config; + changes.push(...webSearchMigration.changes); + } const normalizeBrowserSsrFPolicyAlias = () => { const rawBrowser = next.browser; @@ -597,8 +604,207 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { } }; + const normalizeLegacyTalkConfig = () => { + const rawTalk = next.talk; + if (!isRecord(rawTalk)) { + return; + } + + const normalizedTalk = normalizeTalkSection(rawTalk as OpenClawConfig["talk"]); + if (!normalizedTalk) { + return; + } + + const sameShape = JSON.stringify(normalizedTalk) === JSON.stringify(rawTalk); + if (sameShape) { + return; + } + + const hasProviderShape = typeof rawTalk.provider === "string" || isRecord(rawTalk.providers); + next = { + ...next, + talk: normalizedTalk, + }; + + if (hasProviderShape) { + changes.push( + "Normalized talk.provider/providers shape (trimmed provider ids and merged missing compatibility fields).", + ); + return; + } + + changes.push( + `Moved legacy talk flat fields → talk.provider/talk.providers.${DEFAULT_TALK_PROVIDER}.`, + ); + }; + + const normalizeLegacyCrossContextMessageConfig = () => { + const rawTools = next.tools; + if (!isRecord(rawTools)) { + return; + } + const rawMessage = rawTools.message; + if (!isRecord(rawMessage) || !("allowCrossContextSend" in rawMessage)) { + return; + } + + const legacyAllowCrossContextSend = rawMessage.allowCrossContextSend; + if (typeof legacyAllowCrossContextSend !== "boolean") { + return; + } + + const nextMessage = { ...rawMessage }; + delete nextMessage.allowCrossContextSend; + + if (legacyAllowCrossContextSend) { + const rawCrossContext = isRecord(nextMessage.crossContext) + ? structuredClone(nextMessage.crossContext) + : {}; + rawCrossContext.allowWithinProvider = true; + rawCrossContext.allowAcrossProviders = true; + nextMessage.crossContext = rawCrossContext; + changes.push( + "Moved tools.message.allowCrossContextSend → tools.message.crossContext.allowWithinProvider/allowAcrossProviders (true).", + ); + } else { + changes.push( + "Removed tools.message.allowCrossContextSend=false (default cross-context policy already matches canonical settings).", + ); + } + + next = { + ...next, + tools: { + ...next.tools, + message: nextMessage, + }, + }; + }; + + const mapDeepgramCompatToProviderOptions = ( + rawCompat: Record, + ): Record => { + const providerOptions: Record = {}; + if (typeof rawCompat.detectLanguage === "boolean") { + providerOptions.detect_language = rawCompat.detectLanguage; + } + if (typeof rawCompat.punctuate === "boolean") { + providerOptions.punctuate = rawCompat.punctuate; + } + if (typeof rawCompat.smartFormat === "boolean") { + providerOptions.smart_format = rawCompat.smartFormat; + } + return providerOptions; + }; + + const migrateLegacyDeepgramCompat = (params: { + owner: Record; + pathPrefix: string; + }): boolean => { + const rawCompat = isRecord(params.owner.deepgram) + ? structuredClone(params.owner.deepgram) + : null; + if (!rawCompat) { + return false; + } + + const compatProviderOptions = mapDeepgramCompatToProviderOptions(rawCompat); + const currentProviderOptions = isRecord(params.owner.providerOptions) + ? structuredClone(params.owner.providerOptions) + : {}; + const currentDeepgram = isRecord(currentProviderOptions.deepgram) + ? structuredClone(currentProviderOptions.deepgram) + : {}; + const mergedDeepgram = { ...compatProviderOptions, ...currentDeepgram }; + + delete params.owner.deepgram; + currentProviderOptions.deepgram = mergedDeepgram; + params.owner.providerOptions = currentProviderOptions; + + const hadCanonicalDeepgram = Object.keys(currentDeepgram).length > 0; + changes.push( + hadCanonicalDeepgram + ? `Merged ${params.pathPrefix}.deepgram → ${params.pathPrefix}.providerOptions.deepgram (filled missing canonical fields from legacy).` + : `Moved ${params.pathPrefix}.deepgram → ${params.pathPrefix}.providerOptions.deepgram.`, + ); + return true; + }; + + const normalizeLegacyMediaProviderOptions = () => { + const rawTools = next.tools; + if (!isRecord(rawTools)) { + return; + } + const rawMedia = rawTools.media; + if (!isRecord(rawMedia)) { + return; + } + + let mediaChanged = false; + const nextMedia = structuredClone(rawMedia); + const migrateModelList = (models: unknown, pathPrefix: string): boolean => { + if (!Array.isArray(models)) { + return false; + } + let changed = false; + for (const [index, entry] of models.entries()) { + if (!isRecord(entry)) { + continue; + } + if ( + migrateLegacyDeepgramCompat({ + owner: entry, + pathPrefix: `${pathPrefix}[${index}]`, + }) + ) { + changed = true; + } + } + return changed; + }; + + for (const capability of ["audio", "image", "video"] as const) { + const config = isRecord(nextMedia[capability]) + ? structuredClone(nextMedia[capability]) + : null; + if (!config) { + continue; + } + let configChanged = false; + if (migrateLegacyDeepgramCompat({ owner: config, pathPrefix: `tools.media.${capability}` })) { + configChanged = true; + } + if (migrateModelList(config.models, `tools.media.${capability}.models`)) { + configChanged = true; + } + if (configChanged) { + nextMedia[capability] = config; + mediaChanged = true; + } + } + + if (migrateModelList(nextMedia.models, "tools.media.models")) { + mediaChanged = true; + } + + if (!mediaChanged) { + return; + } + + next = { + ...next, + tools: { + ...next.tools, + media: nextMedia as NonNullable["media"], + }, + }; + }; + normalizeBrowserSsrFPolicyAlias(); normalizeLegacyNanoBananaSkill(); + normalizeLegacyTalkConfig(); + normalizeLegacyCrossContextMessageConfig(); + normalizeLegacyMediaProviderOptions(); const legacyAckReaction = cfg.messages?.ackReaction?.trim(); const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 0d414017c31..2047328433f 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -7,7 +7,7 @@ import { normalizeSecretInputString, } from "../config/types.secrets.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; -import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { SecretInputMode } from "./onboard-types.js"; diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index d89d913fcba..decb5e68e3b 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -16,6 +16,57 @@ vi.mock("../plugins/web-search-providers.js", () => { | undefined )?.entries?.[pluginId]?.config?.webSearch?.apiKey; return { + resolveBundledPluginWebSearchProviders: () => [ + { + id: "brave", + envVars: ["BRAVE_API_KEY"], + credentialPath: "plugins.entries.brave.config.webSearch.apiKey", + getCredentialValue: (search?: Record) => search?.apiKey, + getConfiguredCredentialValue: getConfigured("brave"), + }, + { + id: "firecrawl", + envVars: ["FIRECRAWL_API_KEY"], + credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", + getCredentialValue: getScoped("firecrawl"), + getConfiguredCredentialValue: getConfigured("firecrawl"), + }, + { + id: "gemini", + envVars: ["GEMINI_API_KEY"], + credentialPath: "plugins.entries.google.config.webSearch.apiKey", + getCredentialValue: getScoped("gemini"), + getConfiguredCredentialValue: getConfigured("google"), + }, + { + id: "grok", + envVars: ["XAI_API_KEY"], + credentialPath: "plugins.entries.xai.config.webSearch.apiKey", + getCredentialValue: getScoped("grok"), + getConfiguredCredentialValue: getConfigured("xai"), + }, + { + id: "kimi", + envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"], + credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", + getCredentialValue: getScoped("kimi"), + getConfiguredCredentialValue: getConfigured("moonshot"), + }, + { + id: "perplexity", + envVars: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"], + credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", + getCredentialValue: getScoped("perplexity"), + getConfiguredCredentialValue: getConfigured("perplexity"), + }, + { + id: "tavily", + envVars: ["TAVILY_API_KEY"], + credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", + getCredentialValue: getScoped("tavily"), + getConfiguredCredentialValue: getConfigured("tavily"), + }, + ], resolvePluginWebSearchProviders: () => [ { id: "brave", diff --git a/src/config/doc-baseline.integration.test.ts b/src/config/doc-baseline.integration.test.ts new file mode 100644 index 00000000000..1cb81623889 --- /dev/null +++ b/src/config/doc-baseline.integration.test.ts @@ -0,0 +1,138 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildConfigDocBaseline, + renderConfigDocBaselineStatefile, + writeConfigDocBaselineStatefile, +} from "./doc-baseline.js"; + +describe("config doc baseline integration", () => { + const tempRoots: string[] = []; + let sharedBaselinePromise: Promise>> | null = + null; + let sharedRenderedPromise: Promise< + Awaited> + > | null = null; + + function getSharedBaseline() { + sharedBaselinePromise ??= buildConfigDocBaseline(); + return sharedBaselinePromise; + } + + function getSharedRendered() { + sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline()); + return sharedRenderedPromise; + } + + afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map(async (tempRoot) => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }), + ); + }); + + it("is deterministic across repeated runs", async () => { + const first = await renderConfigDocBaselineStatefile(); + const second = await renderConfigDocBaselineStatefile(); + + expect(second.json).toBe(first.json); + expect(second.jsonl).toBe(first.jsonl); + }); + + it("includes core, channel, and plugin config metadata", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("gateway.auth.token")).toMatchObject({ + kind: "core", + sensitive: true, + }); + expect(byPath.get("channels.telegram.botToken")).toMatchObject({ + kind: "channel", + sensitive: true, + }); + expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({ + kind: "plugin", + sensitive: true, + }); + }); + + it("preserves help text and tags from merged schema hints", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + const tokenEntry = byPath.get("gateway.auth.token"); + + expect(tokenEntry?.help).toContain("gateway access"); + expect(tokenEntry?.tags).toContain("auth"); + expect(tokenEntry?.tags).toContain("security"); + }); + + it("matches array help hints that still use [] notation", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({ + help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"), + sensitive: false, + }); + }); + + it("walks union branches for nested config keys", async () => { + const baseline = await getSharedBaseline(); + const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); + + expect(byPath.get("bindings.*")).toMatchObject({ + hasChildren: true, + }); + expect(byPath.get("bindings.*.type")).toBeDefined(); + expect(byPath.get("bindings.*.match.channel")).toBeDefined(); + expect(byPath.get("bindings.*.match.peer.id")).toBeDefined(); + }); + + it("supports check mode for stale generated artifacts", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-")); + tempRoots.push(tempRoot); + const rendered = getSharedRendered(); + + const initial = await writeConfigDocBaselineStatefile({ + repoRoot: tempRoot, + jsonPath: "docs/.generated/config-baseline.json", + statefilePath: "docs/.generated/config-baseline.jsonl", + rendered, + }); + expect(initial.wrote).toBe(true); + + const current = await writeConfigDocBaselineStatefile({ + repoRoot: tempRoot, + jsonPath: "docs/.generated/config-baseline.json", + statefilePath: "docs/.generated/config-baseline.jsonl", + check: true, + rendered, + }); + expect(current.changed).toBe(false); + + await fs.writeFile( + path.join(tempRoot, "docs/.generated/config-baseline.json"), + '{"generatedBy":"broken","entries":[]}\n', + "utf8", + ); + await fs.writeFile( + path.join(tempRoot, "docs/.generated/config-baseline.jsonl"), + '{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n', + "utf8", + ); + + const stale = await writeConfigDocBaselineStatefile({ + repoRoot: tempRoot, + jsonPath: "docs/.generated/config-baseline.json", + statefilePath: "docs/.generated/config-baseline.jsonl", + check: true, + rendered, + }); + expect(stale.changed).toBe(true); + expect(stale.wrote).toBe(false); + }); +}); diff --git a/src/config/doc-baseline.test.ts b/src/config/doc-baseline.test.ts index a1e670401b1..a86a230bcfc 100644 --- a/src/config/doc-baseline.test.ts +++ b/src/config/doc-baseline.test.ts @@ -1,107 +1,17 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - buildConfigDocBaseline, collectConfigDocBaselineEntries, dedupeConfigDocBaselineEntries, normalizeConfigDocBaselineHelpPath, - renderConfigDocBaselineStatefile, - writeConfigDocBaselineStatefile, } from "./doc-baseline.js"; describe("config doc baseline", () => { - const tempRoots: string[] = []; - let sharedBaselinePromise: Promise>> | null = - null; - let sharedRenderedPromise: Promise< - Awaited> - > | null = null; - - function getSharedBaseline() { - sharedBaselinePromise ??= buildConfigDocBaseline(); - return sharedBaselinePromise; - } - - function getSharedRendered() { - sharedRenderedPromise ??= renderConfigDocBaselineStatefile(getSharedBaseline()); - return sharedRenderedPromise; - } - - afterEach(async () => { - await Promise.all( - tempRoots.splice(0).map(async (tempRoot) => { - await fs.rm(tempRoot, { recursive: true, force: true }); - }), - ); - }); - - it("is deterministic across repeated runs", async () => { - const first = await renderConfigDocBaselineStatefile(); - const second = await renderConfigDocBaselineStatefile(); - - expect(second.json).toBe(first.json); - expect(second.jsonl).toBe(first.jsonl); - }); - it("normalizes array and record paths to wildcard form", async () => { - const baseline = await getSharedBaseline(); - const paths = new Set(baseline.entries.map((entry) => entry.path)); - - expect(paths.has("session.sendPolicy.rules.*.match.keyPrefix")).toBe(true); - expect(paths.has("env.*")).toBe(true); expect(normalizeConfigDocBaselineHelpPath("agents.list[].skills")).toBe("agents.list.*.skills"); - }); - - it("includes core, channel, and plugin config metadata", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - - expect(byPath.get("gateway.auth.token")).toMatchObject({ - kind: "core", - sensitive: true, - }); - expect(byPath.get("channels.telegram.botToken")).toMatchObject({ - kind: "channel", - sensitive: true, - }); - expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({ - kind: "plugin", - sensitive: true, - }); - }); - - it("preserves help text and tags from merged schema hints", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - const tokenEntry = byPath.get("gateway.auth.token"); - - expect(tokenEntry?.help).toContain("gateway access"); - expect(tokenEntry?.tags).toContain("auth"); - expect(tokenEntry?.tags).toContain("security"); - }); - - it("matches array help hints that still use [] notation", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - - expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({ - help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"), - sensitive: false, - }); - }); - - it("walks union branches for nested config keys", async () => { - const baseline = await getSharedBaseline(); - const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry])); - - expect(byPath.get("bindings.*")).toMatchObject({ - hasChildren: true, - }); - expect(byPath.get("bindings.*.type")).toBeDefined(); - expect(byPath.get("bindings.*.match.channel")).toBeDefined(); - expect(byPath.get("bindings.*.match.peer.id")).toBeDefined(); + expect(normalizeConfigDocBaselineHelpPath("session.sendPolicy.rules[0].match.keyPrefix")).toBe( + "session.sendPolicy.rules.*.match.keyPrefix", + ); + expect(normalizeConfigDocBaselineHelpPath(".env.*.")).toBe("env.*"); }); it("merges tuple item metadata instead of dropping earlier entries", () => { @@ -132,48 +42,4 @@ describe("config doc baseline", () => { expect(tupleEntry?.enumValues).toEqual(expect.arrayContaining([42, "alpha"])); expect(tupleEntry?.enumValues).toHaveLength(2); }); - - it("supports check mode for stale generated artifacts", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-")); - tempRoots.push(tempRoot); - const rendered = getSharedRendered(); - - const initial = await writeConfigDocBaselineStatefile({ - repoRoot: tempRoot, - jsonPath: "docs/.generated/config-baseline.json", - statefilePath: "docs/.generated/config-baseline.jsonl", - rendered, - }); - expect(initial.wrote).toBe(true); - - const current = await writeConfigDocBaselineStatefile({ - repoRoot: tempRoot, - jsonPath: "docs/.generated/config-baseline.json", - statefilePath: "docs/.generated/config-baseline.jsonl", - check: true, - rendered, - }); - expect(current.changed).toBe(false); - - await fs.writeFile( - path.join(tempRoot, "docs/.generated/config-baseline.json"), - '{"generatedBy":"broken","entries":[]}\n', - "utf8", - ); - await fs.writeFile( - path.join(tempRoot, "docs/.generated/config-baseline.jsonl"), - '{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n', - "utf8", - ); - - const stale = await writeConfigDocBaselineStatefile({ - repoRoot: tempRoot, - jsonPath: "docs/.generated/config-baseline.json", - statefilePath: "docs/.generated/config-baseline.jsonl", - check: true, - rendered, - }); - expect(stale.changed).toBe(true); - expect(stale.wrote).toBe(false); - }); }); diff --git a/src/config/doc-baseline.ts b/src/config/doc-baseline.ts index 525c91bb521..1603fa3dd1b 100644 --- a/src/config/doc-baseline.ts +++ b/src/config/doc-baseline.ts @@ -1,13 +1,10 @@ -import { spawnSync } from "node:child_process"; import fsSync from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import type { ChannelPlugin } from "../channels/plugins/index.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; -import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { FIELD_HELP } from "./schema.help.js"; -import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js"; +import type { ConfigSchemaResponse } from "./schema.js"; import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js"; type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; @@ -28,12 +25,6 @@ type JsonSchemaObject = JsonSchemaNode & { oneOf?: JsonSchemaObject[]; }; -type PackageChannelMetadata = { - id: string; - label: string; - blurb?: string; -}; - type ChannelSurfaceMetadata = { id: string; label: string; @@ -277,191 +268,11 @@ function resolveFirstExistingPath(candidates: string[]): string | null { return null; } -function loadPackageChannelMetadata(rootDir: string): PackageChannelMetadata | null { - try { - const packageJson = JSON.parse( - fsSync.readFileSync(path.join(rootDir, "package.json"), "utf8"), - ) as { - openclaw?: { - channel?: { - id?: unknown; - label?: unknown; - blurb?: unknown; - }; - }; - }; - const channel = packageJson.openclaw?.channel; - if (!channel) { - return null; - } - const id = typeof channel.id === "string" ? channel.id.trim() : ""; - const label = typeof channel.label === "string" ? channel.label.trim() : ""; - const blurb = typeof channel.blurb === "string" ? channel.blurb.trim() : ""; - if (!id || !label) { - return null; - } - return { - id, - label, - ...(blurb ? { blurb } : {}), - }; - } catch { - return null; - } -} - -function isChannelPlugin(value: unknown): value is ChannelPlugin { - if (!value || typeof value !== "object") { - return false; - } - const candidate = value as { id?: unknown; meta?: unknown; capabilities?: unknown }; - return typeof candidate.id === "string" && typeof candidate.meta === "object"; -} - -function resolveSetupChannelPlugin(value: unknown): ChannelPlugin | null { - if (!value || typeof value !== "object") { - return null; - } - const candidate = value as { plugin?: unknown }; - return isChannelPlugin(candidate.plugin) ? candidate.plugin : null; -} - -async function importChannelPluginModule(rootDir: string): Promise { - logConfigDocBaselineDebug(`resolve channel module ${rootDir}`); - const modulePath = resolveFirstExistingPath([ - path.join(rootDir, "setup-entry.ts"), - path.join(rootDir, "setup-entry.js"), - path.join(rootDir, "setup-entry.mts"), - path.join(rootDir, "setup-entry.mjs"), - path.join(rootDir, "src", "channel.ts"), - path.join(rootDir, "src", "channel.js"), - path.join(rootDir, "src", "plugin.ts"), - path.join(rootDir, "src", "plugin.js"), - path.join(rootDir, "src", "index.ts"), - path.join(rootDir, "src", "index.js"), - path.join(rootDir, "src", "channel.mts"), - path.join(rootDir, "src", "channel.mjs"), - path.join(rootDir, "src", "plugin.mts"), - path.join(rootDir, "src", "plugin.mjs"), - ]); - if (!modulePath) { - throw new Error(`channel source not found under ${rootDir}`); - } - - logConfigDocBaselineDebug(`import channel module ${modulePath}`); - const imported = (await import(modulePath)) as Record; - logConfigDocBaselineDebug(`imported channel module ${modulePath}`); - for (const value of Object.values(imported)) { - if (isChannelPlugin(value)) { - logConfigDocBaselineDebug(`resolved channel export ${modulePath}`); - return value; - } - const setupPlugin = resolveSetupChannelPlugin(value); - if (setupPlugin) { - logConfigDocBaselineDebug(`resolved setup channel export ${modulePath}`); - return setupPlugin; - } - if (typeof value === "function" && value.length === 0) { - const resolved = value(); - if (isChannelPlugin(resolved)) { - logConfigDocBaselineDebug(`resolved channel factory ${modulePath}`); - return resolved; - } - } - } - - throw new Error(`channel plugin export not found in ${modulePath}`); -} - -async function importChannelSurfaceMetadata( - rootDir: string, - repoRoot: string, - env: NodeJS.ProcessEnv, -): Promise { - logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); - const packageMetadata = loadPackageChannelMetadata(rootDir); - if (!packageMetadata) { - logConfigDocBaselineDebug(`missing package channel metadata ${rootDir}`); - return null; - } - - const modulePath = resolveFirstExistingPath([ - path.join(rootDir, "src", "config-schema.ts"), - path.join(rootDir, "src", "config-schema.js"), - path.join(rootDir, "src", "config-schema.mts"), - path.join(rootDir, "src", "config-schema.mjs"), - ]); - if (!modulePath) { - logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`); - return null; - } - - logConfigDocBaselineDebug(`import channel config schema ${modulePath}`); - try { - logConfigDocBaselineDebug(`spawn channel config schema subprocess ${modulePath}`); - const result = spawnSync( - process.execPath, - [ - "--import", - "tsx", - path.join(repoRoot, "scripts", "load-channel-config-surface.ts"), - modulePath, - ], - { - cwd: repoRoot, - encoding: "utf8", - env, - timeout: 15_000, - maxBuffer: 10 * 1024 * 1024, - }, - ); - if (result.status !== 0 || result.error) { - throw result.error ?? new Error(result.stderr || `child exited with status ${result.status}`); - } - logConfigDocBaselineDebug(`completed channel config schema subprocess ${modulePath}`); - const configSchema = JSON.parse(result.stdout) as { - schema: Record; - uiHints?: ConfigSchemaResponse["uiHints"]; - }; - return { - id: packageMetadata.id, - label: packageMetadata.label, - description: packageMetadata.blurb, - configSchema: configSchema.schema, - configUiHints: configSchema.uiHints, - }; - } catch (error) { - logConfigDocBaselineDebug( - `channel config schema subprocess failed for ${modulePath}: ${String(error)}`, - ); - return null; - } -} - -async function loadChannelSurfaceMetadata( - rootDir: string, - repoRoot: string, - env: NodeJS.ProcessEnv, -): Promise { - logConfigDocBaselineDebug(`load channel surface ${rootDir}`); - const configSurface = await importChannelSurfaceMetadata(rootDir, repoRoot, env); - if (configSurface) { - logConfigDocBaselineDebug(`resolved channel config surface ${rootDir}`); - return configSurface; - } - - logConfigDocBaselineDebug(`fallback to channel plugin import ${rootDir}`); - const plugin = await importChannelPluginModule(rootDir); - return { - id: plugin.id, - label: plugin.meta.label, - description: plugin.meta.blurb, - configSchema: plugin.configSchema?.schema, - configUiHints: plugin.configSchema?.uiHints, - }; -} - async function loadBundledConfigSchemaResponse(): Promise { + const [{ loadPluginManifestRegistry }, { buildConfigSchema }] = await Promise.all([ + import("../plugins/manifest-registry.js"), + import("./schema.js"), + ]); const repoRoot = resolveRepoRoot(); const env = { ...process.env, @@ -479,22 +290,49 @@ async function loadBundledConfigSchemaResponse(): Promise const bundledChannelPlugins = manifestRegistry.plugins.filter( (plugin) => plugin.origin === "bundled" && plugin.channels.length > 0, ); - const loadChannelsSequentiallyForDebug = process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1"; - const channelPlugins = loadChannelsSequentiallyForDebug - ? await bundledChannelPlugins.reduce>( - async (promise, plugin) => { - const loaded = await promise; - loaded.push(await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env)); - return loaded; - }, - Promise.resolve([]), - ) - : await Promise.all( - bundledChannelPlugins.map( - async (plugin) => await loadChannelSurfaceMetadata(plugin.rootDir, repoRoot, env), - ), - ); - logConfigDocBaselineDebug(`imported ${channelPlugins.length} bundled channel plugins`); + const channelPlugins = + process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1" + ? await bundledChannelPlugins.reduce>( + async (promise, plugin) => { + const loaded = await promise; + loaded.push( + (await loadChannelSurfaceMetadata( + plugin.rootDir, + plugin.id, + plugin.name ?? plugin.id, + repoRoot, + )) ?? { + id: plugin.id, + label: plugin.name ?? plugin.id, + description: plugin.description, + configSchema: plugin.configSchema, + configUiHints: plugin.configUiHints, + }, + ); + return loaded; + }, + Promise.resolve([]), + ) + : await Promise.all( + bundledChannelPlugins.map( + async (plugin) => + (await loadChannelSurfaceMetadata( + plugin.rootDir, + plugin.id, + plugin.name ?? plugin.id, + repoRoot, + )) ?? { + id: plugin.id, + label: plugin.name ?? plugin.id, + description: plugin.description, + configSchema: plugin.configSchema, + configUiHints: plugin.configUiHints, + }, + ), + ); + logConfigDocBaselineDebug( + `loaded ${channelPlugins.length} bundled channel entries from channel surfaces`, + ); return buildConfigSchema({ cache: false, @@ -517,6 +355,48 @@ async function loadBundledConfigSchemaResponse(): Promise }); } +async function loadChannelSurfaceMetadata( + rootDir: string, + id: string, + label: string, + repoRoot: string, +): Promise { + logConfigDocBaselineDebug(`resolve channel config surface ${rootDir}`); + const modulePath = resolveFirstExistingPath([ + path.join(rootDir, "src", "config-schema.ts"), + path.join(rootDir, "src", "config-schema.js"), + path.join(rootDir, "src", "config-schema.mts"), + path.join(rootDir, "src", "config-schema.mjs"), + ]); + if (!modulePath) { + logConfigDocBaselineDebug(`missing channel config schema module ${rootDir}`); + return null; + } + + logConfigDocBaselineDebug(`import channel config schema ${modulePath}`); + try { + const { loadChannelConfigSurfaceModule } = + await import("../../scripts/load-channel-config-surface.ts"); + const configSurface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot }); + if (!configSurface) { + logConfigDocBaselineDebug(`channel config schema export missing ${modulePath}`); + return null; + } + logConfigDocBaselineDebug(`completed channel config schema import ${modulePath}`); + return { + id, + label, + configSchema: configSurface.schema, + configUiHints: configSurface.uiHints as ConfigSchemaResponse["uiHints"] | undefined, + }; + } catch (error) { + logConfigDocBaselineDebug( + `channel config schema import failed for ${modulePath}: ${String(error)}`, + ); + return null; + } +} + export function collectConfigDocBaselineEntries( schema: JsonSchemaObject, uiHints: ConfigSchemaResponse["uiHints"], diff --git a/src/config/legacy-web-search.ts b/src/config/legacy-web-search.ts index 4aaef33fc95..c9ca80e7970 100644 --- a/src/config/legacy-web-search.ts +++ b/src/config/legacy-web-search.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "./config.js"; +import { mergeMissing } from "./legacy.shared.js"; type JsonRecord = Record; @@ -57,19 +58,60 @@ function copyLegacyProviderConfig( return isRecord(current) ? cloneRecord(current) : undefined; } -function setPluginWebSearchConfig( - target: JsonRecord, - pluginId: string, - webSearchConfig: JsonRecord, -): void { - const plugins = ensureRecord(target, "plugins"); +function hasOwnKey(target: JsonRecord, key: string): boolean { + return Object.prototype.hasOwnProperty.call(target, key); +} + +function hasMappedLegacyWebSearchConfig(raw: unknown): boolean { + const search = resolveLegacySearchConfig(raw); + if (!search) { + return false; + } + if (hasOwnKey(search, "apiKey")) { + return true; + } + return (Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]).some((providerId) => + isRecord(search[providerId]), + ); +} + +function migratePluginWebSearchConfig(params: { + root: JsonRecord; + legacyPath: string; + targetPath: string; + pluginId: string; + payload: JsonRecord; + changes: string[]; +}) { + const plugins = ensureRecord(params.root, "plugins"); const entries = ensureRecord(plugins, "entries"); - const entry = ensureRecord(entries, pluginId); - if (entry.enabled === undefined) { + const entry = ensureRecord(entries, params.pluginId); + const config = ensureRecord(entry, "config"); + const hadEnabled = entry.enabled !== undefined; + const existing = isRecord(config.webSearch) ? cloneRecord(config.webSearch) : undefined; + + if (!hadEnabled) { entry.enabled = true; } - const config = ensureRecord(entry, "config"); - config.webSearch = webSearchConfig; + + if (!existing) { + config.webSearch = cloneRecord(params.payload); + params.changes.push(`Moved ${params.legacyPath} → ${params.targetPath}.`); + return; + } + + const merged = cloneRecord(existing); + mergeMissing(merged, params.payload); + const changed = JSON.stringify(merged) !== JSON.stringify(existing) || !hadEnabled; + config.webSearch = merged; + if (changed) { + params.changes.push( + `Merged ${params.legacyPath} → ${params.targetPath} (filled missing fields from legacy; kept explicit plugin config values).`, + ); + return; + } + + params.changes.push(`Removed ${params.legacyPath} (${params.targetPath} already set).`); } export function listLegacyWebSearchConfigPaths(raw: unknown): string[] { @@ -103,24 +145,73 @@ export function normalizeLegacyWebSearchConfig(raw: T): T { return raw; } + return normalizeLegacyWebSearchConfigRecord(raw).config; +} + +export function migrateLegacyWebSearchConfig(raw: T): { config: T; changes: string[] } { + if (!isRecord(raw)) { + return { config: raw, changes: [] }; + } + + if (!hasMappedLegacyWebSearchConfig(raw)) { + return { config: raw, changes: [] }; + } + + return normalizeLegacyWebSearchConfigRecord(raw); +} + +function normalizeLegacyWebSearchConfigRecord( + raw: T, +): { + config: T; + changes: string[]; +} { const nextRoot = cloneRecord(raw); const tools = ensureRecord(nextRoot, "tools"); const web = ensureRecord(tools, "web"); + const search = resolveLegacySearchConfig(nextRoot); + if (!search) { + return { config: raw, changes: [] }; + } const nextSearch: JsonRecord = {}; + const changes: string[] = []; for (const [key, value] of Object.entries(search)) { - if (GENERIC_WEB_SEARCH_KEYS.has(key)) { + if (key === "apiKey") { + continue; + } + if ( + (Object.keys(LEGACY_PROVIDER_MAP) as LegacyProviderId[]).includes(key as LegacyProviderId) + ) { + if (isRecord(value)) { + continue; + } + } + if (GENERIC_WEB_SEARCH_KEYS.has(key) || !isRecord(value)) { nextSearch[key] = value; } } web.search = nextSearch; - const braveConfig = copyLegacyProviderConfig(search, "brave") ?? {}; - if ("apiKey" in search) { + const legacyBraveConfig = copyLegacyProviderConfig(search, "brave"); + const braveConfig = legacyBraveConfig ?? {}; + if (hasOwnKey(search, "apiKey")) { braveConfig.apiKey = search.apiKey; } if (Object.keys(braveConfig).length > 0) { - setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP.brave, braveConfig); + migratePluginWebSearchConfig({ + root: nextRoot, + legacyPath: hasOwnKey(search, "apiKey") + ? "tools.web.search.apiKey" + : "tools.web.search.brave", + targetPath: + hasOwnKey(search, "apiKey") && !legacyBraveConfig + ? "plugins.entries.brave.config.webSearch.apiKey" + : "plugins.entries.brave.config.webSearch", + pluginId: LEGACY_PROVIDER_MAP.brave, + payload: braveConfig, + changes, + }); } for (const providerId of [ @@ -135,10 +226,17 @@ export function normalizeLegacyWebSearchConfig(raw: T): T { if (!scoped || Object.keys(scoped).length === 0) { continue; } - setPluginWebSearchConfig(nextRoot, LEGACY_PROVIDER_MAP[providerId], scoped); + migratePluginWebSearchConfig({ + root: nextRoot, + legacyPath: `tools.web.search.${providerId}`, + targetPath: `plugins.entries.${LEGACY_PROVIDER_MAP[providerId]}.config.webSearch`, + pluginId: LEGACY_PROVIDER_MAP[providerId], + payload: scoped, + changes, + }); } - return nextRoot as T; + return { config: nextRoot, changes }; } export function resolvePluginWebSearchConfig( diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 36de7dbc775..258d26e7ee4 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -9,6 +9,7 @@ export { readNumberParam, readStringArrayParam, readStringParam } from "../agent export { resolveCitationRedirectUrl } from "../agents/tools/web-search-citation-redirect.js"; export { buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, DEFAULT_SEARCH_COUNT, FRESHNESS_TO_RECENCY, isoToPerplexityDate, @@ -29,6 +30,7 @@ export { export { getScopedCredentialValue, getTopLevelCredentialValue, + mergeScopedSearchConfig, resolveProviderWebSearchPluginConfig, setScopedCredentialValue, setProviderWebSearchPluginConfigValue, diff --git a/src/plugins/loader.git-path-regression.test.ts b/src/plugins/loader.git-path-regression.test.ts new file mode 100644 index 00000000000..fde7d6554bc --- /dev/null +++ b/src/plugins/loader.git-path-regression.test.ts @@ -0,0 +1,98 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { __testing } from "./loader.js"; + +type CreateJiti = typeof import("jiti").createJiti; + +let createJitiPromise: Promise | undefined; + +async function getCreateJiti() { + createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti); + return createJitiPromise; +} + +const tempRoots: string[] = []; + +function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-loader-")); + tempRoots.push(dir); + return dir; +} + +function mkdirSafe(dir: string) { + fs.mkdirSync(dir, { recursive: true }); +} + +afterEach(() => { + for (const dir of tempRoots.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("plugin loader git path regression", () => { + it("loads git-style package extension entries when they import plugin-sdk channel-runtime (#49806)", async () => { + const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage"); + const copiedSourceDir = path.join(copiedExtensionRoot, "src"); + const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); + mkdirSafe(copiedSourceDir); + mkdirSafe(copiedPluginSdkDir); + + const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs"); + fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8"); + fs.writeFileSync( + path.join(copiedSourceDir, "channel.runtime.ts"), + `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js"; + +export const copiedRuntimeMarker = { + resolveOutboundSendDep, + PAIRING_APPROVED_MESSAGE, +}; +`, + "utf-8", + ); + fs.writeFileSync( + path.join(copiedExtensionRoot, "runtime-api.ts"), + `export const PAIRING_APPROVED_MESSAGE = "paired"; +`, + "utf-8", + ); + const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts"); + fs.writeFileSync( + copiedChannelRuntimeShim, + `export function resolveOutboundSendDep() { + return "shimmed"; +} +`, + "utf-8", + ); + + const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts"); + const jitiBaseUrl = pathToFileURL(jitiBaseFile).href; + const createJiti = await getCreateJiti(); + const withoutAlias = createJiti(jitiBaseUrl, { + ...__testing.buildPluginLoaderJitiOptions({}), + tryNative: false, + }); + // Jiti's pre-alias failure text varies across Node versions and platforms. + // The contract is simply that the source import rejects until the scoped + // plugin-sdk alias is applied. + await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(); + + const withAlias = createJiti(jitiBaseUrl, { + ...__testing.buildPluginLoaderJitiOptions({ + "openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim, + }), + tryNative: false, + }); + await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({ + copiedRuntimeMarker: { + PAIRING_APPROVED_MESSAGE: "paired", + resolveOutboundSendDep: expect.any(Function), + }, + }); + }); +}); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 489ab3ce294..4f6132a3bd5 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,11 +1,18 @@ -import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; -import { createJiti } from "jiti"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; +type CreateJiti = typeof import("jiti").createJiti; + +let createJitiPromise: Promise | undefined; + +async function getCreateJiti() { + createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti); + return createJitiPromise; +} + async function importFreshPluginTestModules() { vi.resetModules(); vi.doUnmock("node:fs"); @@ -3244,42 +3251,24 @@ module.exports = { body: `module.exports = { id: "legacy-root-import", configSchema: (require("openclaw/plugin-sdk").emptyPluginConfigSchema)(), - register() {}, -};`, + register() {}, + };`, }); - const loaderModuleUrl = pathToFileURL( - path.join(process.cwd(), "src", "plugins", "loader.ts"), - ).href; - const script = ` - import { loadOpenClawPlugins } from ${JSON.stringify(loaderModuleUrl)}; - const registry = loadOpenClawPlugins({ + const registry = withEnv({ OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins" }, () => + loadOpenClawPlugins({ cache: false, - workspaceDir: ${JSON.stringify(plugin.dir)}, + workspaceDir: plugin.dir, config: { plugins: { - load: { paths: [${JSON.stringify(plugin.file)}] }, + load: { paths: [plugin.file] }, allow: ["legacy-root-import"], }, }, - }); - const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); - if (!record || record.status !== "loaded") { - console.error(record?.error ?? "legacy-root-import missing"); - process.exit(1); - } - `; - - execFileSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], { - cwd: process.cwd(), - env: { - ...process.env, - OPENCLAW_HOME: undefined, - OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins", - }, - encoding: "utf-8", - stdio: "pipe", - }); + }), + ); + const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); + expect(record?.status).toBe("loaded"); }); it.each([ @@ -3572,25 +3561,7 @@ module.exports = { }); it("loads source runtime shims through the non-native Jiti boundary", async () => { - const jiti = createJiti(import.meta.url, { - ...__testing.buildPluginLoaderJitiOptions(__testing.resolvePluginSdkScopedAliasMap()), - tryNative: false, - }); - const discordChannelRuntime = path.join( - process.cwd(), - "extensions", - "discord", - "src", - "channel.runtime.ts", - ); - - await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({ - discordSetupWizard: expect.any(Object), - }); - }, 240_000); - - it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => { - const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage"); + const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord"); const copiedSourceDir = path.join(copiedExtensionRoot, "src"); const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk"); mkdirSafe(copiedSourceDir); @@ -3600,18 +3571,10 @@ module.exports = { fs.writeFileSync( path.join(copiedSourceDir, "channel.runtime.ts"), `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js"; -export const copiedRuntimeMarker = { +export const syntheticRuntimeMarker = { resolveOutboundSendDep, - PAIRING_APPROVED_MESSAGE, }; -`, - "utf-8", - ); - fs.writeFileSync( - path.join(copiedExtensionRoot, "runtime-api.ts"), - `export const PAIRING_APPROVED_MESSAGE = "paired"; `, "utf-8", ); @@ -3627,13 +3590,15 @@ export const copiedRuntimeMarker = { const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts"); const jitiBaseUrl = pathToFileURL(jitiBaseFile).href; + const createJiti = await getCreateJiti(); const withoutAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({}), tryNative: false, }); - await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow( - /plugin-sdk\/channel-runtime/, - ); + // Jiti's pre-alias failure text varies across Node versions and platforms. + // This boundary only needs to prove the source import rejects until the + // plugin-sdk alias is present. + await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(); const withAlias = createJiti(jitiBaseUrl, { ...__testing.buildPluginLoaderJitiOptions({ @@ -3642,94 +3607,11 @@ export const copiedRuntimeMarker = { tryNative: false, }); await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({ - copiedRuntimeMarker: { - PAIRING_APPROVED_MESSAGE: "paired", + syntheticRuntimeMarker: { resolveOutboundSendDep: expect.any(Function), }, }); - }); - - it("loads git-style package extension entries through the plugin loader when they import plugin-sdk channel-runtime (#49806)", () => { - useNoBundledPlugins(); - const pluginId = "imessage-loader-regression"; - const gitExtensionRoot = path.join( - makeTempDir(), - "git-source-checkout", - "extensions", - pluginId, - ); - const gitSourceDir = path.join(gitExtensionRoot, "src"); - mkdirSafe(gitSourceDir); - - fs.writeFileSync( - path.join(gitExtensionRoot, "package.json"), - JSON.stringify( - { - name: `@openclaw/${pluginId}`, - version: "0.0.1", - type: "module", - openclaw: { - extensions: ["./src/index.ts"], - }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(gitExtensionRoot, "openclaw.plugin.json"), - JSON.stringify( - { - id: pluginId, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(gitSourceDir, "channel.runtime.ts"), - `import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; - -export function runtimeProbeType() { - return typeof resolveOutboundSendDep; -} -`, - "utf-8", - ); - fs.writeFileSync( - path.join(gitSourceDir, "index.ts"), - `import { runtimeProbeType } from "./channel.runtime.ts"; - -export default { - id: ${JSON.stringify(pluginId)}, - register() { - if (runtimeProbeType() !== "function") { - throw new Error("channel-runtime import did not resolve"); - } - }, -}; -`, - "utf-8", - ); - - const registry = withEnv({ NODE_ENV: "production", VITEST: undefined }, () => - loadOpenClawPlugins({ - cache: false, - workspaceDir: gitExtensionRoot, - config: { - plugins: { - load: { paths: [gitExtensionRoot] }, - allow: [pluginId], - }, - }, - }), - ); - const record = registry.plugins.find((entry) => entry.id === pluginId); - expect(record?.status).toBe("loaded"); - }); + }, 240_000); it("loads source TypeScript plugins that route through local runtime shims", () => { const plugin = writePlugin({ diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts new file mode 100644 index 00000000000..27478cbb1a1 --- /dev/null +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; +import { + resolvePluginWebSearchProviders, + resolveRuntimeWebSearchProviders, +} from "./web-search-providers.runtime.js"; + +const BUNDLED_WEB_SEARCH_PROVIDERS = [ + { pluginId: "brave", id: "brave", order: 10 }, + { pluginId: "google", id: "gemini", order: 20 }, + { pluginId: "xai", id: "grok", order: 30 }, + { pluginId: "moonshot", id: "kimi", order: 40 }, + { pluginId: "perplexity", id: "perplexity", order: 50 }, + { pluginId: "firecrawl", id: "firecrawl", order: 60 }, + { pluginId: "tavily", id: "tavily", order: 70 }, +] as const; + +const { loadOpenClawPluginsMock } = vi.hoisted(() => ({ + loadOpenClawPluginsMock: vi.fn((params?: { config?: { plugins?: Record } }) => { + const plugins = params?.config?.plugins as + | { + enabled?: boolean; + allow?: string[]; + entries?: Record; + } + | undefined; + if (plugins?.enabled === false) { + return { webSearchProviders: [] }; + } + const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null; + const entries = plugins?.entries ?? {}; + const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => { + if (allow && !allow.includes(provider.pluginId)) { + return false; + } + if (entries[provider.pluginId]?.enabled === false) { + return false; + } + return true; + }).map((provider) => ({ + pluginId: provider.pluginId, + pluginName: provider.pluginId, + source: "test" as const, + provider: { + id: provider.id, + label: provider.id, + hint: `${provider.id} provider`, + envVars: [`${provider.id.toUpperCase()}_API_KEY`], + placeholder: `${provider.id}-...`, + signupUrl: `https://example.com/${provider.id}`, + autoDetectOrder: provider.order, + credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`, + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: provider.id, + parameters: {}, + execute: async () => ({}), + }), + }, + })); + return { webSearchProviders }; + }), +})); + +vi.mock("./loader.js", () => ({ + loadOpenClawPlugins: loadOpenClawPluginsMock, +})); + +describe("resolvePluginWebSearchProviders", () => { + beforeEach(() => { + loadOpenClawPluginsMock.mockClear(); + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + afterEach(() => { + setActivePluginRegistry(createEmptyPluginRegistry()); + }); + + it("loads bundled providers through the plugin loader in auto-detect order", () => { + const providers = resolvePluginWebSearchProviders({}); + + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "brave:brave", + "google:gemini", + "xai:grok", + "moonshot:kimi", + "perplexity:perplexity", + "firecrawl:firecrawl", + "tavily:tavily", + ]); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); + }); + + it("prefers the active plugin registry for runtime resolution", () => { + const registry = createEmptyPluginRegistry(); + registry.webSearchProviders.push({ + pluginId: "custom-search", + pluginName: "Custom Search", + provider: { + id: "custom", + label: "Custom Search", + hint: "Custom runtime provider", + envVars: ["CUSTOM_SEARCH_API_KEY"], + placeholder: "custom-...", + signupUrl: "https://example.com/signup", + autoDetectOrder: 1, + credentialPath: "tools.web.search.custom.apiKey", + getCredentialValue: () => "configured", + setCredentialValue: () => {}, + createTool: () => ({ + description: "custom", + parameters: {}, + execute: async () => ({}), + }), + }, + source: "test", + }); + setActivePluginRegistry(registry); + + const providers = resolveRuntimeWebSearchProviders({}); + + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "custom-search:custom", + ]); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts new file mode 100644 index 00000000000..494936d9857 --- /dev/null +++ b/src/plugins/web-search-providers.runtime.ts @@ -0,0 +1,56 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { loadOpenClawPlugins } from "./loader.js"; +import type { PluginLoadOptions } from "./loader.js"; +import { createPluginLoaderLogger } from "./logger.js"; +import { getActivePluginRegistry } from "./runtime.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; +import { + resolveBundledWebSearchResolutionConfig, + sortWebSearchProviders, +} from "./web-search-providers.shared.js"; + +const log = createSubsystemLogger("plugins"); + +export function resolvePluginWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; + activate?: boolean; + cache?: boolean; +}): PluginWebSearchProviderEntry[] { + const { config } = resolveBundledWebSearchResolutionConfig(params); + const registry = loadOpenClawPlugins({ + config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache ?? false, + activate: params.activate ?? false, + logger: createPluginLoaderLogger(log), + }); + + return sortWebSearchProviders( + registry.webSearchProviders.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), + ); +} + +export function resolveRuntimeWebSearchProviders(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; +}): PluginWebSearchProviderEntry[] { + const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; + if (runtimeProviders.length > 0) { + return sortWebSearchProviders( + runtimeProviders.map((entry) => ({ + ...entry.provider, + pluginId: entry.pluginId, + })), + ); + } + return resolvePluginWebSearchProviders(params); +} diff --git a/src/plugins/web-search-providers.shared.ts b/src/plugins/web-search-providers.shared.ts new file mode 100644 index 00000000000..29ba9527590 --- /dev/null +++ b/src/plugins/web-search-providers.shared.ts @@ -0,0 +1,120 @@ +import { + withBundledPluginAllowlistCompat, + withBundledPluginEnablementCompat, +} from "./bundled-compat.js"; +import { resolveBundledWebSearchPluginIds } from "./bundled-web-search.js"; +import { normalizePluginsConfig, type NormalizedPluginsConfig } from "./config-state.js"; +import type { PluginLoadOptions } from "./loader.js"; +import type { PluginWebSearchProviderEntry } from "./types.js"; + +export function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { + const plugins = config?.plugins; + if (!plugins) { + return false; + } + if (typeof plugins.enabled === "boolean") { + return true; + } + if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { + return true; + } + if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { + return true; + } + if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { + return true; + } + if (plugins.entries && Object.keys(plugins.entries).length > 0) { + return true; + } + if (plugins.slots && Object.keys(plugins.slots).length > 0) { + return true; + } + return false; +} + +function resolveBundledWebSearchCompatPluginIds(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): string[] { + return resolveBundledWebSearchPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); +} + +function withBundledWebSearchVitestCompat(params: { + config: PluginLoadOptions["config"]; + pluginIds: readonly string[]; + env?: PluginLoadOptions["env"]; +}): PluginLoadOptions["config"] { + const env = params.env ?? process.env; + const isVitest = Boolean(env.VITEST || process.env.VITEST); + if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) { + return params.config; + } + + return { + ...params.config, + plugins: { + ...params.config?.plugins, + enabled: true, + allow: [...params.pluginIds], + slots: { + ...params.config?.plugins?.slots, + memory: "none", + }, + }, + }; +} + +export function sortWebSearchProviders( + providers: PluginWebSearchProviderEntry[], +): PluginWebSearchProviderEntry[] { + return providers.toSorted((a, b) => { + const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; + if (aOrder !== bOrder) { + return aOrder - bOrder; + } + return a.id.localeCompare(b.id); + }); +} + +export function resolveBundledWebSearchResolutionConfig(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; +}): { + config: PluginLoadOptions["config"]; + normalized: NormalizedPluginsConfig; +} { + const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + const allowlistCompat = params.bundledAllowlistCompat + ? withBundledPluginAllowlistCompat({ + config: params.config, + pluginIds: bundledCompatPluginIds, + }) + : params.config; + const enablementCompat = withBundledPluginEnablementCompat({ + config: allowlistCompat, + pluginIds: bundledCompatPluginIds, + }); + const config = withBundledWebSearchVitestCompat({ + config: enablementCompat, + pluginIds: bundledCompatPluginIds, + env: params.env, + }); + + return { + config, + normalized: normalizePluginsConfig(config?.plugins), + }; +} diff --git a/src/plugins/web-search-providers.test.ts b/src/plugins/web-search-providers.test.ts index 420ad8e4ab7..694a9cc6f28 100644 --- a/src/plugins/web-search-providers.test.ts +++ b/src/plugins/web-search-providers.test.ts @@ -1,95 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; -import { createEmptyPluginRegistry } from "./registry.js"; -import { setActivePluginRegistry } from "./runtime.js"; -import { - resolveBundledPluginWebSearchProviders, - resolvePluginWebSearchProviders, - resolveRuntimeWebSearchProviders, -} from "./web-search-providers.js"; - -const BUNDLED_WEB_SEARCH_PROVIDERS = [ - { pluginId: "baidu", id: "baidu", order: 5 }, - { pluginId: "brave", id: "brave", order: 10 }, - { pluginId: "google", id: "gemini", order: 20 }, - { pluginId: "xai", id: "grok", order: 30 }, - { pluginId: "moonshot", id: "kimi", order: 40 }, - { pluginId: "perplexity", id: "perplexity", order: 50 }, - { pluginId: "firecrawl", id: "firecrawl", order: 60 }, - { pluginId: "tavily", id: "tavily", order: 70 }, -] as const; - -const { loadOpenClawPluginsMock } = vi.hoisted(() => ({ - loadOpenClawPluginsMock: vi.fn((params?: { config?: { plugins?: Record } }) => { - const plugins = params?.config?.plugins as - | { - enabled?: boolean; - allow?: string[]; - entries?: Record; - } - | undefined; - if (plugins?.enabled === false) { - return { webSearchProviders: [] }; - } - const allow = Array.isArray(plugins?.allow) && plugins.allow.length > 0 ? plugins.allow : null; - const entries = plugins?.entries ?? {}; - const webSearchProviders = BUNDLED_WEB_SEARCH_PROVIDERS.filter((provider) => { - if (allow && !allow.includes(provider.pluginId)) { - return false; - } - if (entries[provider.pluginId]?.enabled === false) { - return false; - } - return true; - }).map((provider) => ({ - pluginId: provider.pluginId, - pluginName: provider.pluginId, - source: "test" as const, - provider: { - id: provider.id, - label: provider.id, - hint: `${provider.id} provider`, - envVars: [`${provider.id.toUpperCase()}_API_KEY`], - placeholder: `${provider.id}-...`, - signupUrl: `https://example.com/${provider.id}`, - autoDetectOrder: provider.order, - credentialPath: `plugins.entries.${provider.pluginId}.config.webSearch.apiKey`, - getCredentialValue: () => "configured", - setCredentialValue: () => {}, - applySelectionConfig: - provider.id === "firecrawl" ? (config: OpenClawConfig) => config : undefined, - resolveRuntimeMetadata: - provider.id === "perplexity" - ? () => ({ - perplexityTransport: "search_api" as const, - }) - : undefined, - createTool: () => ({ - description: provider.id, - parameters: {}, - execute: async () => ({}), - }), - }, - })); - return { webSearchProviders }; - }), -})); - -vi.mock("./loader.js", () => ({ - loadOpenClawPlugins: loadOpenClawPluginsMock, -})); - -describe("resolvePluginWebSearchProviders", () => { - beforeEach(() => { - loadOpenClawPluginsMock.mockClear(); - }); - - afterEach(() => { - setActivePluginRegistry(createEmptyPluginRegistry()); - }); +import { describe, expect, it } from "vitest"; +import { resolveBundledPluginWebSearchProviders } from "./web-search-providers.js"; +describe("resolveBundledPluginWebSearchProviders", () => { it("returns bundled providers in auto-detect order", () => { - const providers = resolvePluginWebSearchProviders({}); + const providers = resolveBundledPluginWebSearchProviders({}); expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ "baidu:baidu", @@ -120,7 +34,7 @@ describe("resolvePluginWebSearchProviders", () => { }); it("can augment restrictive allowlists for bundled compatibility", () => { - const providers = resolvePluginWebSearchProviders({ + const providers = resolveBundledPluginWebSearchProviders({ config: { plugins: { allow: ["openrouter"], @@ -142,7 +56,7 @@ describe("resolvePluginWebSearchProviders", () => { }); it("does not return bundled providers excluded by a restrictive allowlist without compat", () => { - const providers = resolvePluginWebSearchProviders({ + const providers = resolveBundledPluginWebSearchProviders({ config: { plugins: { allow: ["openrouter"], @@ -154,7 +68,7 @@ describe("resolvePluginWebSearchProviders", () => { }); it("preserves explicit bundled provider entry state", () => { - const providers = resolvePluginWebSearchProviders({ + const providers = resolveBundledPluginWebSearchProviders({ config: { plugins: { entries: { @@ -168,7 +82,7 @@ describe("resolvePluginWebSearchProviders", () => { }); it("returns no providers when plugins are globally disabled", () => { - const providers = resolvePluginWebSearchProviders({ + const providers = resolveBundledPluginWebSearchProviders({ config: { plugins: { enabled: false, @@ -194,7 +108,6 @@ describe("resolvePluginWebSearchProviders", () => { "firecrawl:firecrawl", "tavily:tavily", ]); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("can scope bundled resolution to one plugin id", () => { @@ -215,39 +128,5 @@ describe("resolvePluginWebSearchProviders", () => { expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ "google:gemini", ]); - expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); - }); - - it("prefers the active plugin registry for runtime resolution", () => { - const registry = createEmptyPluginRegistry(); - registry.webSearchProviders.push({ - pluginId: "custom-search", - pluginName: "Custom Search", - provider: { - id: "custom", - label: "Custom Search", - hint: "Custom runtime provider", - envVars: ["CUSTOM_SEARCH_API_KEY"], - placeholder: "custom-...", - signupUrl: "https://example.com/signup", - autoDetectOrder: 1, - credentialPath: "tools.web.search.custom.apiKey", - getCredentialValue: () => "configured", - setCredentialValue: () => {}, - createTool: () => ({ - description: "custom", - parameters: {}, - execute: async () => ({}), - }), - }, - source: "test", - }); - setActivePluginRegistry(registry); - - const providers = resolveRuntimeWebSearchProviders({}); - - expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ - "custom-search:custom", - ]); }); }); diff --git a/src/plugins/web-search-providers.ts b/src/plugins/web-search-providers.ts index 81acd38c827..f61cdbd5362 100644 --- a/src/plugins/web-search-providers.ts +++ b/src/plugins/web-search-providers.ts @@ -1,135 +1,11 @@ -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { - withBundledPluginAllowlistCompat, - withBundledPluginEnablementCompat, -} from "./bundled-compat.js"; -import { - listBundledWebSearchProviders as listBundledWebSearchProviderEntries, - resolveBundledWebSearchPluginIds, -} from "./bundled-web-search.js"; -import { - normalizePluginsConfig, - resolveEffectiveEnableState, - type NormalizedPluginsConfig, -} from "./config-state.js"; -import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; -import { createPluginLoaderLogger } from "./logger.js"; -import { getActivePluginRegistry } from "./runtime.js"; +import { listBundledWebSearchProviders as listBundledWebSearchProviderEntries } from "./bundled-web-search.js"; +import { resolveEffectiveEnableState } from "./config-state.js"; +import type { PluginLoadOptions } from "./loader.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; - -const log = createSubsystemLogger("plugins"); - -function hasExplicitPluginConfig(config: PluginLoadOptions["config"]): boolean { - const plugins = config?.plugins; - if (!plugins) { - return false; - } - if (typeof plugins.enabled === "boolean") { - return true; - } - if (Array.isArray(plugins.allow) && plugins.allow.length > 0) { - return true; - } - if (Array.isArray(plugins.deny) && plugins.deny.length > 0) { - return true; - } - if (Array.isArray(plugins.load?.paths) && plugins.load.paths.length > 0) { - return true; - } - if (plugins.entries && Object.keys(plugins.entries).length > 0) { - return true; - } - if (plugins.slots && Object.keys(plugins.slots).length > 0) { - return true; - } - return false; -} - -function resolveBundledWebSearchCompatPluginIds(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - env?: PluginLoadOptions["env"]; -}): string[] { - return resolveBundledWebSearchPluginIds({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); -} - -function withBundledWebSearchVitestCompat(params: { - config: PluginLoadOptions["config"]; - pluginIds: readonly string[]; - env?: PluginLoadOptions["env"]; -}): PluginLoadOptions["config"] { - const env = params.env ?? process.env; - const isVitest = Boolean(env.VITEST || process.env.VITEST); - if (!isVitest || hasExplicitPluginConfig(params.config) || params.pluginIds.length === 0) { - return params.config; - } - - return { - ...params.config, - plugins: { - ...params.config?.plugins, - enabled: true, - allow: [...params.pluginIds], - slots: { - ...params.config?.plugins?.slots, - memory: "none", - }, - }, - }; -} - -function sortWebSearchProviders( - providers: PluginWebSearchProviderEntry[], -): PluginWebSearchProviderEntry[] { - return providers.toSorted((a, b) => { - const aOrder = a.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const bOrder = b.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - if (aOrder !== bOrder) { - return aOrder - bOrder; - } - return a.id.localeCompare(b.id); - }); -} - -function resolveBundledWebSearchResolutionConfig(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; -}): { - config: PluginLoadOptions["config"]; - normalized: NormalizedPluginsConfig; -} { - const bundledCompatPluginIds = resolveBundledWebSearchCompatPluginIds({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - const allowlistCompat = params.bundledAllowlistCompat - ? withBundledPluginAllowlistCompat({ - config: params.config, - pluginIds: bundledCompatPluginIds, - }) - : params.config; - const enablementCompat = withBundledPluginEnablementCompat({ - config: allowlistCompat, - pluginIds: bundledCompatPluginIds, - }); - const config = withBundledWebSearchVitestCompat({ - config: enablementCompat, - pluginIds: bundledCompatPluginIds, - env: params.env, - }); - - return { - config, - normalized: normalizePluginsConfig(config?.plugins), - }; -} +import { + resolveBundledWebSearchResolutionConfig, + sortWebSearchProviders, +} from "./web-search-providers.shared.js"; function listBundledWebSearchProviders(): PluginWebSearchProviderEntry[] { return sortWebSearchProviders(listBundledWebSearchProviderEntries()); @@ -158,47 +34,3 @@ export function resolveBundledPluginWebSearchProviders(params: { }).enabled; }); } - -export function resolvePluginWebSearchProviders(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; - activate?: boolean; - cache?: boolean; -}): PluginWebSearchProviderEntry[] { - const { config } = resolveBundledWebSearchResolutionConfig(params); - const registry = loadOpenClawPlugins({ - config, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache ?? false, - activate: params.activate ?? false, - logger: createPluginLoaderLogger(log), - }); - - return sortWebSearchProviders( - registry.webSearchProviders.map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), - ); -} - -export function resolveRuntimeWebSearchProviders(params: { - config?: PluginLoadOptions["config"]; - workspaceDir?: string; - env?: PluginLoadOptions["env"]; - bundledAllowlistCompat?: boolean; -}): PluginWebSearchProviderEntry[] { - const runtimeProviders = getActivePluginRegistry()?.webSearchProviders ?? []; - if (runtimeProviders.length > 0) { - return sortWebSearchProviders( - runtimeProviders.map((entry) => ({ - ...entry.provider, - pluginId: entry.pluginId, - })), - ); - } - return resolvePluginWebSearchProviders(params); -} diff --git a/src/scripts/ci-changed-scope.test.ts b/src/scripts/ci-changed-scope.test.ts index 682cfb8d9b3..284851c5694 100644 --- a/src/scripts/ci-changed-scope.test.ts +++ b/src/scripts/ci-changed-scope.test.ts @@ -124,6 +124,16 @@ describe("detectChangedScope", () => { }); }); + it("runs Python skill tests when shared Python config changes", () => { + expect(detectChangedScope(["pyproject.toml"])).toEqual({ + runNode: true, + runMacos: false, + runAndroid: false, + runWindows: false, + runSkillsPython: true, + }); + }); + it("runs platform lanes when the CI workflow changes", () => { expect(detectChangedScope([".github/workflows/ci.yml"])).toEqual({ runNode: true, diff --git a/src/secrets/runtime-web-tools.test.ts b/src/secrets/runtime-web-tools.test.ts index e0a78fc05cc..a091ffb11b8 100644 --- a/src/secrets/runtime-web-tools.test.ts +++ b/src/secrets/runtime-web-tools.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginWebSearchProviderEntry } from "../plugins/types.js"; -import * as webSearchProviders from "../plugins/web-search-providers.js"; +import * as bundledWebSearchProviders from "../plugins/web-search-providers.js"; +import * as runtimeWebSearchProviders from "../plugins/web-search-providers.runtime.js"; import * as secretResolve from "./resolve.js"; import { createResolverContext } from "./runtime-shared.js"; import { resolveRuntimeWebTools } from "./runtime-web-tools.js"; @@ -18,6 +19,9 @@ const { resolveBundledPluginWebSearchProvidersMock } = vi.hoisted(() => ({ vi.mock("../plugins/web-search-providers.js", () => ({ resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock, +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, })); @@ -181,8 +185,8 @@ function expectInactiveFirecrawlSecretRef(params: { describe("runtime web tools resolution", () => { beforeEach(() => { - vi.mocked(webSearchProviders.resolvePluginWebSearchProviders).mockClear(); - vi.mocked(webSearchProviders.resolveBundledPluginWebSearchProviders).mockClear(); + vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders).mockClear(); + vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders).mockClear(); }); afterEach(() => { @@ -190,7 +194,7 @@ describe("runtime web tools resolution", () => { }); it("skips loading web search providers when search config is absent", async () => { - const providerSpy = vi.mocked(webSearchProviders.resolvePluginWebSearchProviders); + const providerSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders); const { metadata } = await runRuntimeWebTools({ config: asConfig({ @@ -538,8 +542,8 @@ describe("runtime web tools resolution", () => { }); it("uses bundled provider resolution for configured bundled providers", async () => { - const bundledSpy = vi.mocked(webSearchProviders.resolveBundledPluginWebSearchProviders); - const genericSpy = vi.mocked(webSearchProviders.resolvePluginWebSearchProviders); + const bundledSpy = vi.mocked(bundledWebSearchProviders.resolveBundledPluginWebSearchProviders); + const genericSpy = vi.mocked(runtimeWebSearchProviders.resolvePluginWebSearchProviders); const { metadata } = await runRuntimeWebTools({ config: asConfig({ diff --git a/src/secrets/runtime-web-tools.ts b/src/secrets/runtime-web-tools.ts index 5c8993829ac..8794567f98b 100644 --- a/src/secrets/runtime-web-tools.ts +++ b/src/secrets/runtime-web-tools.ts @@ -8,10 +8,8 @@ import type { PluginWebSearchProviderEntry, WebSearchCredentialResolutionSource, } from "../plugins/types.js"; -import { - resolveBundledPluginWebSearchProviders, - resolvePluginWebSearchProviders, -} from "../plugins/web-search-providers.js"; +import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js"; +import { resolvePluginWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { secretRefKey } from "./ref-contract.js"; import { resolveSecretRefValues } from "./resolve.js"; diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index 5c7ca6d71ae..bce2911b88f 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -16,11 +16,14 @@ const { resolveBundledPluginWebSearchProvidersMock, resolvePluginWebSearchProvid vi.mock("../plugins/web-search-providers.js", () => ({ resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock, +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, })); function createTestProvider(params: { - id: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl"; + id: "brave" | "gemini" | "grok" | "kimi" | "perplexity" | "firecrawl" | "tavily"; pluginId: string; order: number; }): PluginWebSearchProviderEntry { @@ -80,6 +83,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] { createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }), createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }), createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }), + createTestProvider({ id: "tavily", pluginId: "tavily", order: 70 }), ]; } @@ -194,6 +198,9 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string) if (entry.id === "plugins.entries.firecrawl.config.webSearch.apiKey") { setPathCreateStrict(config, ["tools", "web", "search", "provider"], "firecrawl"); } + if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") { + setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily"); + } return config; } diff --git a/src/secrets/runtime.test.ts b/src/secrets/runtime.test.ts index 12792f7c2f1..40824a522af 100644 --- a/src/secrets/runtime.test.ts +++ b/src/secrets/runtime.test.ts @@ -22,6 +22,9 @@ const { resolveBundledPluginWebSearchProvidersMock, resolvePluginWebSearchProvid vi.mock("../plugins/web-search-providers.js", () => ({ resolveBundledPluginWebSearchProviders: resolveBundledPluginWebSearchProvidersMock, +})); + +vi.mock("../plugins/web-search-providers.runtime.js", () => ({ resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock, })); diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts new file mode 100644 index 00000000000..6564cf5c641 --- /dev/null +++ b/src/shared/assistant-error-format.ts @@ -0,0 +1,188 @@ +const ERROR_PAYLOAD_PREFIX_RE = + /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i; +const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i; +const HTTP_STATUS_CODE_PREFIX_RE = /^(?:http\s*)?(\d{3})(?:\s+([\s\S]+))?$/i; +const HTML_ERROR_PREFIX_RE = /^\s*(?:; + +export type ApiErrorInfo = { + httpCode?: string; + type?: string; + message?: string; + requestId?: string; +}; + +function isErrorPayloadObject(payload: unknown): payload is ErrorPayload { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return false; + } + const record = payload as ErrorPayload; + if (record.type === "error") { + return true; + } + if (typeof record.request_id === "string" || typeof record.requestId === "string") { + return true; + } + if ("error" in record) { + const err = record.error; + if (err && typeof err === "object" && !Array.isArray(err)) { + const errRecord = err as ErrorPayload; + if ( + typeof errRecord.message === "string" || + typeof errRecord.type === "string" || + typeof errRecord.code === "string" + ) { + return true; + } + } + } + return false; +} + +function parseApiErrorPayload(raw: string): ErrorPayload | null { + if (!raw) { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const candidates = [trimmed]; + if (ERROR_PAYLOAD_PREFIX_RE.test(trimmed)) { + candidates.push(trimmed.replace(ERROR_PAYLOAD_PREFIX_RE, "").trim()); + } + for (const candidate of candidates) { + if (!candidate.startsWith("{") || !candidate.endsWith("}")) { + continue; + } + try { + const parsed = JSON.parse(candidate) as unknown; + if (isErrorPayloadObject(parsed)) { + return parsed; + } + } catch { + // ignore parse errors + } + } + return null; +} + +export function extractLeadingHttpStatus(raw: string): { code: number; rest: string } | null { + const match = raw.match(HTTP_STATUS_CODE_PREFIX_RE); + if (!match) { + return null; + } + const code = Number(match[1]); + if (!Number.isFinite(code)) { + return null; + } + return { code, rest: (match[2] ?? "").trim() }; +} + +export function isCloudflareOrHtmlErrorPage(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + + const status = extractLeadingHttpStatus(trimmed); + if (!status || status.code < 500) { + return false; + } + + if (CLOUDFLARE_HTML_ERROR_CODES.has(status.code)) { + return true; + } + + return ( + status.code < 600 && HTML_ERROR_PREFIX_RE.test(status.rest) && /<\/html>/i.test(status.rest) + ); +} + +export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null { + if (!raw) { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + + let httpCode: string | undefined; + let candidate = trimmed; + + const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); + if (httpPrefixMatch) { + httpCode = httpPrefixMatch[1]; + candidate = httpPrefixMatch[2].trim(); + } + + const payload = parseApiErrorPayload(candidate); + if (!payload) { + return null; + } + + const requestId = + typeof payload.request_id === "string" + ? payload.request_id + : typeof payload.requestId === "string" + ? payload.requestId + : undefined; + + const topType = typeof payload.type === "string" ? payload.type : undefined; + const topMessage = typeof payload.message === "string" ? payload.message : undefined; + + let errType: string | undefined; + let errMessage: string | undefined; + if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { + const err = payload.error as Record; + if (typeof err.type === "string") { + errType = err.type; + } + if (typeof err.code === "string" && !errType) { + errType = err.code; + } + if (typeof err.message === "string") { + errMessage = err.message; + } + } + + return { + httpCode, + type: errType ?? topType, + message: errMessage ?? topMessage, + requestId, + }; +} + +export function formatRawAssistantErrorForUi(raw?: string): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) { + return "LLM request failed with an unknown error."; + } + + const leadingStatus = extractLeadingHttpStatus(trimmed); + if (leadingStatus && isCloudflareOrHtmlErrorPage(trimmed)) { + return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`; + } + + const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE); + if (httpMatch) { + const rest = httpMatch[2].trim(); + if (!rest.startsWith("{")) { + return `HTTP ${httpMatch[1]}: ${rest}`; + } + } + + const info = parseApiErrorInfo(trimmed); + if (info?.message) { + const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; + const type = info.type ? ` ${info.type}` : ""; + const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; + return `${prefix}${type}: ${info.message}${requestId}`; + } + + return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; +} diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 566839c7cf1..a47e4177215 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -1,5 +1,5 @@ -import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js"; import { stripLeadingInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js"; +import { formatRawAssistantErrorForUi } from "../shared/assistant-error-format.js"; import { stripAnsi } from "../terminal/ansi.js"; import { formatTokenCount } from "../utils/usage-format.js"; diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 06c56f1ec27..e19ba5d6a6e 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -5,10 +5,8 @@ import type { PluginWebSearchProviderEntry, WebSearchProviderToolDefinition, } from "../plugins/types.js"; -import { - resolvePluginWebSearchProviders, - resolveRuntimeWebSearchProviders, -} from "../plugins/web-search-providers.js"; +import { resolveBundledPluginWebSearchProviders } from "../plugins/web-search-providers.js"; +import { resolveRuntimeWebSearchProviders } from "../plugins/web-search-providers.runtime.js"; import type { RuntimeWebSearchMetadata } from "../secrets/runtime-web-tools.types.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; @@ -97,7 +95,7 @@ export function resolveWebSearchProviderId(params: { }): string { const providers = params.providers ?? - resolvePluginWebSearchProviders({ + resolveBundledPluginWebSearchProviders({ config: params.config, bundledAllowlistCompat: true, }); @@ -142,7 +140,7 @@ export function resolveWebSearchDefinition( config: options?.config, bundledAllowlistCompat: true, }) - : resolvePluginWebSearchProviders({ + : resolveBundledPluginWebSearchProviders({ config: options?.config, bundledAllowlistCompat: true, }) diff --git a/test/fixtures/test-memory-hotspots.unit.json b/test/fixtures/test-memory-hotspots.unit.json new file mode 100644 index 00000000000..4a345aacaf2 --- /dev/null +++ b/test/fixtures/test-memory-hotspots.unit.json @@ -0,0 +1,171 @@ +{ + "config": "vitest.unit.config.ts", + "generatedAt": "2026-03-20T04:59:15.285Z", + "defaultMinDeltaKb": 262144, + "lane": "unit-fast", + "files": { + "src/config/schema.help.quality.test.ts": { + "deltaKb": 1111491, + "sources": [ + "gha-23328306205-compat-node22:unit-fast", + "gha-23328306205-node-test-2-2:unit-fast" + ] + }, + "src/plugins/conversation-binding.test.ts": { + "deltaKb": 787149, + "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + }, + "src/infra/outbound/targets.test.ts": { + "deltaKb": 784179, + "sources": ["job2:unit-fast"] + }, + "src/plugins/contracts/wizard.contract.test.ts": { + "deltaKb": 783770, + "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + }, + "ui/src/ui/views/chat.test.ts": { + "deltaKb": 740864, + "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + }, + "src/cron/isolated-agent.uses-last-non-empty-agent-text-as.test.ts": { + "deltaKb": 652288, + "sources": ["gha-23328306205-node-test-1-2:unit-fast"] + }, + "src/plugins/install.test.ts": { + "deltaKb": 545485, + "sources": ["gha-23328306205-compat-node22:unit-fast"] + }, + "src/tui/tui.submit-handler.test.ts": { + "deltaKb": 528486, + "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + }, + "src/media-understanding/resolve.test.ts": { + "deltaKb": 516506, + "sources": ["job1:unit-fast"] + }, + "src/infra/provider-usage.auth.normalizes-keys.test.ts": { + "deltaKb": 510362, + "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + }, + "src/acp/client.test.ts": { + "deltaKb": 491213, + "sources": ["job2:unit-fast"] + }, + "src/infra/update-runner.test.ts": { + "deltaKb": 474726, + "sources": ["gha-23328306205-node-test-1-2:unit-fast"] + }, + "src/secrets/runtime-web-tools.test.ts": { + "deltaKb": 473190, + "sources": ["job1:unit-fast"] + }, + "src/cron/isolated-agent/run.cron-model-override.test.ts": { + "deltaKb": 469914, + "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + }, + "src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts": { + "deltaKb": 457421, + "sources": ["gha-23328306205-node-test-1-2:unit-fast"] + }, + "src/cron/isolated-agent/run.skill-filter.test.ts": { + "deltaKb": 446054, + "sources": ["gha-23328306205-compat-node22:unit-fast"] + }, + "src/plugins/interactive.test.ts": { + "deltaKb": 441242, + "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + }, + "src/infra/run-node.test.ts": { + "deltaKb": 427213, + "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + }, + "src/media-understanding/runner.video.test.ts": { + "deltaKb": 402739, + "sources": ["job1:unit-fast"] + }, + "src/infra/provider-usage.test.ts": { + "deltaKb": 389837, + "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + }, + "src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts": { + "deltaKb": 377446, + "sources": ["gha-23328306205-compat-node22:unit-fast"] + }, + "src/infra/outbound/agent-delivery.test.ts": { + "deltaKb": 373043, + "sources": ["job1:unit-fast"] + }, + "src/cron/isolated-agent/delivery-dispatch.named-agent.test.ts": { + "deltaKb": 355840, + "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + }, + "src/infra/state-migrations.test.ts": { + "deltaKb": 345805, + "sources": ["gha-23328306205-node-test-1-2:unit-fast"] + }, + "src/config/sessions/store.pruning.integration.test.ts": { + "deltaKb": 342221, + "sources": ["gha-23328306205-node-test-1-2:unit-fast"] + }, + "src/channels/plugins/contracts/outbound-payload.contract.test.ts": { + "deltaKb": 335565, + "sources": ["gha-23329089711-node-test-1-2:unit-fast"] + }, + "src/infra/outbound/outbound-policy.test.ts": { + "deltaKb": 334950, + "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + }, + "src/config/sessions/store.pruning.test.ts": { + "deltaKb": 333312, + "sources": ["job2:unit-fast"] + }, + "src/media-understanding/providers/moonshot/video.test.ts": { + "deltaKb": 333005, + "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + }, + "src/infra/heartbeat-runner.model-override.test.ts": { + "deltaKb": 325632, + "sources": ["job1:unit-fast"] + }, + "src/config/sessions.test.ts": { + "deltaKb": 324813, + "sources": ["gha-23329089711-node-test-2-2:unit-fast"] + }, + "src/acp/translator.cancel-scoping.test.ts": { + "deltaKb": 324403, + "sources": ["gha-23328306205-node-test-1-2:unit-fast"] + }, + "src/infra/heartbeat-runner.ghost-reminder.test.ts": { + "deltaKb": 321536, + "sources": ["job1:unit-fast"] + }, + "src/tui/tui-session-actions.test.ts": { + "deltaKb": 319898, + "sources": ["gha-23328306205-compat-node22:unit-fast"] + }, + "src/infra/outbound/message-action-runner.context.test.ts": { + "deltaKb": 318157, + "sources": ["gha-23328306205-compat-node22:unit-fast"] + }, + "src/cron/service.store-load-invalid-main-job.test.ts": { + "deltaKb": 308019, + "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + }, + "src/channels/plugins/outbound/signal.test.ts": { + "deltaKb": 301056, + "sources": ["job2:unit-fast"] + }, + "src/cron/service.store-migration.test.ts": { + "deltaKb": 282931, + "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + }, + "src/media-understanding/providers/google/video.test.ts": { + "deltaKb": 274022, + "sources": ["gha-23328306205-node-test-2-2:unit-fast"] + }, + "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts": { + "deltaKb": 267366, + "sources": ["job2:unit-fast"] + } + } +} diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json index a9e0d95569f..2de992a45d5 100644 --- a/test/fixtures/test-parallel.behavior.json +++ b/test/fixtures/test-parallel.behavior.json @@ -14,7 +14,7 @@ "reason": "Mutates process.cwd() and core loader seams." }, { - "file": "src/config/doc-baseline.test.ts", + "file": "src/config/doc-baseline.integration.test.ts", "reason": "Rebuilds bundled config baselines through many channel schema subprocesses; keep out of the shared lane." }, { @@ -28,7 +28,7 @@ "reason": "Clean in isolation, but can hang after sharing the broad lane." }, { - "file": "src/config/doc-baseline.test.ts", + "file": "src/config/doc-baseline.integration.test.ts", "reason": "Builds the full bundled config schema graph and is safer outside the shared unit-fast heap." }, { diff --git a/test/scripts/test-parallel.test.ts b/test/scripts/test-parallel.test.ts index 5d88f50e9e1..0f4a91c85a4 100644 --- a/test/scripts/test-parallel.test.ts +++ b/test/scripts/test-parallel.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseCompletedTestFileLines } from "../../scripts/test-parallel-memory.mjs"; +import { + parseCompletedTestFileLines, + parseMemoryTraceSummaryLines, + parseMemoryValueKb, +} from "../../scripts/test-parallel-memory.mjs"; import { appendCapturedOutput, hasFatalTestRunOutput, @@ -76,4 +80,31 @@ describe("scripts/test-parallel memory trace parsing", () => { ), ).toEqual([]); }); + + it("parses memory trace summary lines and hotspot deltas", () => { + const summaries = parseMemoryTraceSummaryLines( + [ + "2026-03-20T04:32:18.7721466Z [test-parallel][mem] summary unit-fast files=360 peak=13.22GiB totalDelta=6.69GiB peakAt=poll top=src/config/schema.help.quality.test.ts:1.06GiB, src/infra/update-runner.test.ts:+463.6MiB", + ].join("\n"), + ); + + expect(summaries).toHaveLength(1); + expect(summaries[0]).toEqual({ + lane: "unit-fast", + files: 360, + peakRssKb: parseMemoryValueKb("13.22GiB"), + totalDeltaKb: parseMemoryValueKb("6.69GiB"), + peakAt: "poll", + top: [ + { + file: "src/config/schema.help.quality.test.ts", + deltaKb: parseMemoryValueKb("1.06GiB"), + }, + { + file: "src/infra/update-runner.test.ts", + deltaKb: parseMemoryValueKb("+463.6MiB"), + }, + ], + }); + }); }); diff --git a/test/scripts/test-runner-manifest.test.ts b/test/scripts/test-runner-manifest.test.ts new file mode 100644 index 00000000000..cd650ae2aad --- /dev/null +++ b/test/scripts/test-runner-manifest.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + selectMemoryHeavyFiles, + selectTimedHeavyFiles, + selectUnitHeavyFileGroups, +} from "../../scripts/test-runner-manifest.mjs"; + +describe("scripts/test-runner-manifest timed selection", () => { + it("only selects known timed heavy files above the minimum", () => { + expect( + selectTimedHeavyFiles({ + candidates: ["a.test.ts", "b.test.ts", "c.test.ts"], + limit: 3, + minDurationMs: 1000, + exclude: new Set(["c.test.ts"]), + timings: { + defaultDurationMs: 250, + files: { + "a.test.ts": { durationMs: 2500 }, + "b.test.ts": { durationMs: 900 }, + "c.test.ts": { durationMs: 5000 }, + }, + }, + }), + ).toEqual(["a.test.ts"]); + }); +}); + +describe("scripts/test-runner-manifest memory selection", () => { + it("selects known memory hotspots above the minimum", () => { + expect( + selectMemoryHeavyFiles({ + candidates: ["a.test.ts", "b.test.ts", "c.test.ts", "d.test.ts"], + limit: 3, + minDeltaKb: 256 * 1024, + exclude: new Set(["c.test.ts"]), + hotspots: { + files: { + "a.test.ts": { deltaKb: 600 * 1024 }, + "b.test.ts": { deltaKb: 120 * 1024 }, + "c.test.ts": { deltaKb: 900 * 1024 }, + }, + }, + }), + ).toEqual(["a.test.ts"]); + }); + + it("orders selected memory hotspots by descending retained heap", () => { + expect( + selectMemoryHeavyFiles({ + candidates: ["a.test.ts", "b.test.ts", "c.test.ts"], + limit: 2, + minDeltaKb: 1, + hotspots: { + files: { + "a.test.ts": { deltaKb: 300 }, + "b.test.ts": { deltaKb: 700 }, + "c.test.ts": { deltaKb: 500 }, + }, + }, + }), + ).toEqual(["b.test.ts", "c.test.ts"]); + }); + + it("gives memory-heavy isolation precedence over timed-heavy buckets", () => { + expect( + selectUnitHeavyFileGroups({ + candidates: ["overlap.test.ts", "memory-only.test.ts", "timed-only.test.ts"], + behaviorOverrides: new Set(), + timedLimit: 3, + timedMinDurationMs: 1000, + memoryLimit: 3, + memoryMinDeltaKb: 256 * 1024, + timings: { + defaultDurationMs: 250, + files: { + "overlap.test.ts": { durationMs: 5000 }, + "timed-only.test.ts": { durationMs: 4200 }, + }, + }, + hotspots: { + files: { + "overlap.test.ts": { deltaKb: 900 * 1024 }, + "memory-only.test.ts": { deltaKb: 700 * 1024 }, + }, + }, + }), + ).toEqual({ + memoryHeavyFiles: ["overlap.test.ts", "memory-only.test.ts"], + timedHeavyFiles: ["timed-only.test.ts"], + }); + }); +}); diff --git a/test/vitest-unit-config.test.ts b/test/vitest-unit-config.test.ts new file mode 100644 index 00000000000..312d468a28b --- /dev/null +++ b/test/vitest-unit-config.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + loadExtraExcludePatternsFromEnv, + loadIncludePatternsFromEnv, +} from "../vitest.unit.config.ts"; + +const tempDirs = new Set(); + +afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.clear(); +}); + +const writePatternFile = (basename: string, value: unknown) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-vitest-unit-config-")); + tempDirs.add(dir); + const filePath = path.join(dir, basename); + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); + return filePath; +}; + +describe("loadIncludePatternsFromEnv", () => { + it("returns null when no include file is configured", () => { + expect(loadIncludePatternsFromEnv({})).toBeNull(); + }); + + it("loads include patterns from a JSON file", () => { + const filePath = writePatternFile("include.json", [ + "src/infra/update-runner.test.ts", + 42, + "", + "ui/src/ui/views/chat.test.ts", + ]); + + expect( + loadIncludePatternsFromEnv({ + OPENCLAW_VITEST_INCLUDE_FILE: filePath, + }), + ).toEqual(["src/infra/update-runner.test.ts", "ui/src/ui/views/chat.test.ts"]); + }); +}); + +describe("loadExtraExcludePatternsFromEnv", () => { + it("returns an empty list when no extra exclude file is configured", () => { + expect(loadExtraExcludePatternsFromEnv({})).toEqual([]); + }); + + it("loads extra exclude patterns from a JSON file", () => { + const filePath = writePatternFile("extra-exclude.json", [ + "src/infra/update-runner.test.ts", + 42, + "", + "ui/src/ui/views/chat.test.ts", + ]); + + expect( + loadExtraExcludePatternsFromEnv({ + OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE: filePath, + }), + ).toEqual(["src/infra/update-runner.test.ts", "ui/src/ui/views/chat.test.ts"]); + }); + + it("throws when the configured file is not a JSON array", () => { + const filePath = writePatternFile("extra-exclude.json", { + exclude: ["src/infra/update-runner.test.ts"], + }); + + expect(() => + loadExtraExcludePatternsFromEnv({ + OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE: filePath, + }), + ).toThrow(/JSON array/u); + }); +}); diff --git a/vitest.unit.config.ts b/vitest.unit.config.ts index ab6757c3351..02db81f84bb 100644 --- a/vitest.unit.config.ts +++ b/vitest.unit.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { defineConfig } from "vitest/config"; import baseConfig from "./vitest.config.ts"; import { @@ -8,12 +9,45 @@ import { const base = baseConfig as unknown as Record; const baseTest = (baseConfig as { test?: { include?: string[]; exclude?: string[] } }).test ?? {}; const exclude = baseTest.exclude ?? []; +function loadPatternListFile(filePath: string, label: string): string[] { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; + if (!Array.isArray(parsed)) { + throw new TypeError(`${label} must point to a JSON array: ${filePath}`); + } + return parsed.filter((value): value is string => typeof value === "string" && value.length > 0); +} + +export function loadIncludePatternsFromEnv( + env: Record = process.env, +): string[] | null { + const includeFile = env.OPENCLAW_VITEST_INCLUDE_FILE?.trim(); + if (!includeFile) { + return null; + } + return loadPatternListFile(includeFile, "OPENCLAW_VITEST_INCLUDE_FILE"); +} + +export function loadExtraExcludePatternsFromEnv( + env: Record = process.env, +): string[] { + const extraExcludeFile = env.OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE?.trim(); + if (!extraExcludeFile) { + return []; + } + return loadPatternListFile(extraExcludeFile, "OPENCLAW_VITEST_EXTRA_EXCLUDE_FILE"); +} export default defineConfig({ ...base, test: { ...baseTest, - include: unitTestIncludePatterns, - exclude: [...exclude, ...unitTestAdditionalExcludePatterns], + include: loadIncludePatternsFromEnv() ?? unitTestIncludePatterns, + exclude: [ + ...new Set([ + ...exclude, + ...unitTestAdditionalExcludePatterns, + ...loadExtraExcludePatternsFromEnv(), + ]), + ], }, });