Merge branch 'main' into web-tools
This commit is contained in:
commit
d6b0673714
77
.github/workflows/ci.yml
vendored
77
.github/workflows/ci.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@ -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'
|
||||
|
||||
@ -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-<version>-play-release.aab`
|
||||
- Third-party build: `apps/android/build/release-bundles/openclaw-<version>-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:
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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<InvokeCommandSpec> =
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<PermissionToggle?>(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<String>()
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
13
apps/android/app/src/play/AndroidManifest.xml
Normal file
13
apps/android/app/src/play/AndroidManifest.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission
|
||||
android:name="android.permission.SEND_SMS"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_SMS"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_CALL_LOG"
|
||||
tools:node="remove" />
|
||||
</manifest>
|
||||
@ -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,
|
||||
|
||||
@ -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<void> {
|
||||
await $`jarsigner -verify ${path}`.quiet();
|
||||
}
|
||||
|
||||
async function copyBundle(sourcePath: string, destinationPath: string): Promise<void> {
|
||||
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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -83,7 +83,7 @@ Notes:
|
||||
|
||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||
- `--target` accepts `channel:<id>` 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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
|
||||
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
|
||||
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
|
||||
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||
- Microsoft Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
|
||||
|
||||
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`
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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"`).
|
||||
|
||||
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -763,7 +763,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
baseUrl: "http://localhost",
|
||||
deploySecret: "a",
|
||||
clientId: applicationId,
|
||||
commandDeploymentMode: "reconcile",
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: false,
|
||||
|
||||
@ -166,13 +166,6 @@ function createTopicEvent(messageId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function settleAsyncWork(): Promise<void> {
|
||||
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<string, (data: unknown) => Promise<void>>) => {
|
||||
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();
|
||||
|
||||
@ -155,13 +155,6 @@ function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function settleAsyncWork(): Promise<void> {
|
||||
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<string, (data: unknown) => Promise<void>>) => {
|
||||
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);
|
||||
|
||||
@ -184,13 +184,6 @@ function createBroadcastEvent(messageId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function settleAsyncWork(): Promise<void> {
|
||||
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<string, (data: unknown) => Promise<void>>) => {
|
||||
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();
|
||||
|
||||
@ -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<void> {
|
||||
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<string, (data: unknown) => Promise<void>>) => {
|
||||
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);
|
||||
|
||||
@ -167,13 +167,6 @@ function createTextEvent(messageId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function settleAsyncWork(): Promise<void> {
|
||||
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<string, (data: unknown) => Promise<void>>) => {
|
||||
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);
|
||||
|
||||
@ -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<string, unknown>): unknown {
|
||||
const scoped = searchConfig?.firecrawl;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
return undefined;
|
||||
}
|
||||
return (scoped as Record<string, unknown>).apiKey;
|
||||
}
|
||||
|
||||
function setScopedCredentialValue(
|
||||
searchConfigTarget: Record<string, unknown>,
|
||||
value: unknown,
|
||||
): void {
|
||||
const scoped = searchConfigTarget.firecrawl;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.firecrawl = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).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) => {
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>).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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>).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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<string, unknown>).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<string, unknown>).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<string, unknown>
|
||||
| undefined),
|
||||
...(resolveProviderWebSearchPluginConfig(ctx.config, "perplexity") as
|
||||
| Record<string, unknown>
|
||||
| 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,
|
||||
),
|
||||
};
|
||||
|
||||
@ -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<string, unknown>): unknown {
|
||||
const scoped = searchConfig?.tavily;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
return undefined;
|
||||
}
|
||||
return (scoped as Record<string, unknown>).apiKey;
|
||||
}
|
||||
|
||||
function setScopedCredentialValue(
|
||||
searchConfigTarget: Record<string, unknown>,
|
||||
value: unknown,
|
||||
): void {
|
||||
const scoped = searchConfigTarget.tavily;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.tavily = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).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) => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -67,6 +67,7 @@ export type TelegramThreadBindingManager = {
|
||||
type TelegramThreadBindingsState = {
|
||||
managersByAccountId: Map<string, TelegramThreadBindingManager>;
|
||||
bindingsByAccountConversation: Map<string, TelegramThreadBindingRecord>;
|
||||
persistQueueByAccountId: Map<string, Promise<void>>;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -80,10 +81,12 @@ const threadBindingsState = resolveGlobalSingleton<TelegramThreadBindingsState>(
|
||||
() => ({
|
||||
managersByAccountId: new Map<string, TelegramThreadBindingManager>(),
|
||||
bindingsByAccountConversation: new Map<string, TelegramThreadBindingRecord>(),
|
||||
persistQueueByAccountId: new Map<string, Promise<void>>(),
|
||||
}),
|
||||
);
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
},
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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<string, unknown>).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<string, unknown>).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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<string, unknown>;
|
||||
vi.mock("./accounts.js", () => {
|
||||
return {
|
||||
...actual,
|
||||
listZalouserAccountIds: () => ["default"],
|
||||
resolveDefaultZalouserAccountId: () => "default",
|
||||
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
|
||||
resolveZalouserAccount: async () => createDefaultResolvedZalouserAccount(),
|
||||
listEnabledZalouserAccounts: async () => [createDefaultResolvedZalouserAccount()],
|
||||
getZcaUserInfo: async () => null,
|
||||
checkZcaAuthenticated: async () => false,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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<string, unknown>;
|
||||
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();
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Reactions } from "./zca-client.js";
|
||||
import { Reactions } from "./zca-constants.js";
|
||||
|
||||
const REACTION_ALIAS_MAP = new Map<string, string>([
|
||||
["like", Reactions.LIKE],
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<typeof import("./zalo-js.js")>();
|
||||
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({
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Style } from "./zca-client.js";
|
||||
import type { Style } from "./zca-constants.js";
|
||||
|
||||
export type ZcaFriend = {
|
||||
userId: string;
|
||||
|
||||
60
extensions/zalouser/src/zalo-js.test-mocks.ts
Normal file
60
extensions/zalouser/src/zalo-js.test-mocks.ts
Normal file
@ -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,
|
||||
}));
|
||||
@ -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;
|
||||
|
||||
@ -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<string, string> & {
|
||||
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<TextStyleValue, typeof TextStyle.Indent>;
|
||||
}
|
||||
| {
|
||||
start: number;
|
||||
len: number;
|
||||
st: typeof TextStyle.Indent;
|
||||
indentSize?: number;
|
||||
};
|
||||
export { LoginQRCallbackEventType, Reactions, TextStyle, ThreadType };
|
||||
export type { Style };
|
||||
|
||||
export type Credentials = {
|
||||
imei: string;
|
||||
|
||||
55
extensions/zalouser/src/zca-constants.ts
Normal file
55
extensions/zalouser/src/zca-constants.ts
Normal file
@ -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<TextStyleValue, typeof TextStyle.Indent>;
|
||||
}
|
||||
| {
|
||||
start: number;
|
||||
len: number;
|
||||
st: typeof TextStyle.Indent;
|
||||
indentSize?: number;
|
||||
};
|
||||
13
package.json
13
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",
|
||||
|
||||
@ -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\/)/;
|
||||
|
||||
@ -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 =
|
||||
/(?<file>(?:src|extensions|test|ui)\/\S+?\.(?:live\.test|e2e\.test|test)\.ts)\s+\(.*\)\s+(?<duration>\d+(?:\.\d+)?)(?<unit>ms|s)\s*$/;
|
||||
const MEMORY_TRACE_SUMMARY_PATTERN =
|
||||
/^\[test-parallel\]\[mem\] summary (?<lane>\S+) files=(?<files>\d+) peak=(?<peak>[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB)) totalDelta=(?<totalDelta>[+-]?[0-9]+(?:\.[0-9]+)?(?:GiB|MiB|KiB)) peakAt=(?<peakAt>\S+) top=(?<top>.*)$/u;
|
||||
const MEMORY_TRACE_TOP_ENTRY_PATTERN =
|
||||
/^(?<file>(?:src|extensions|test|ui)\/\S+?\.(?:live\.test|e2e\.test|test)\.ts):(?<delta>[+-]?[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(/^(?<sign>[+-]?)(?<value>\d+(?:\.\d+)?)(?<unit>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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
152
scripts/test-update-memory-hotspots.mjs
Normal file
152
scripts/test-update-memory-hotspots.mjs
Normal file
@ -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 <path>.");
|
||||
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}`,
|
||||
);
|
||||
@ -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*(?:<!doctype\s+html\b|<html\b)/i;
|
||||
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
|
||||
const TRANSIENT_HTTP_ERROR_CODES = new Set([499, 500, 502, 503, 504, 521, 522, 523, 524, 529]);
|
||||
const HTTP_ERROR_HINTS = [
|
||||
"error",
|
||||
@ -348,38 +355,6 @@ function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailover
|
||||
return classify402Message(raw);
|
||||
}
|
||||
|
||||
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 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<string, unknown>;
|
||||
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 },
|
||||
|
||||
@ -21,6 +21,13 @@ export type SearchConfigRecord = (NonNullable<OpenClawConfig["tools"]>["web"] ex
|
||||
: never) &
|
||||
Record<string, unknown>;
|
||||
|
||||
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<string, unknown>,
|
||||
): 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<string, unknown>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -71,6 +71,37 @@ export function setScopedCredentialValue(
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
}
|
||||
|
||||
export function mergeScopedSearchConfig(
|
||||
searchConfig: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
pluginConfig: Record<string, unknown> | undefined,
|
||||
options?: { mirrorApiKeyToTopLevel?: boolean },
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!pluginConfig) {
|
||||
return searchConfig;
|
||||
}
|
||||
|
||||
const currentScoped =
|
||||
searchConfig?.[key] &&
|
||||
typeof searchConfig[key] === "object" &&
|
||||
!Array.isArray(searchConfig[key])
|
||||
? (searchConfig[key] as Record<string, unknown>)
|
||||
: {};
|
||||
const next: Record<string, unknown> = {
|
||||
...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") {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, unknown>,
|
||||
): Record<string, string | number | boolean> => {
|
||||
const providerOptions: Record<string, string | number | boolean> = {};
|
||||
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<string, unknown>;
|
||||
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<OpenClawConfig["tools"]>["media"],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
normalizeBrowserSsrFPolicyAlias();
|
||||
normalizeLegacyNanoBananaSkill();
|
||||
normalizeLegacyTalkConfig();
|
||||
normalizeLegacyCrossContextMessageConfig();
|
||||
normalizeLegacyMediaProviderOptions();
|
||||
|
||||
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
|
||||
const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<string, unknown>) => 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",
|
||||
|
||||
138
src/config/doc-baseline.integration.test.ts
Normal file
138
src/config/doc-baseline.integration.test.ts
Normal file
@ -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<Awaited<ReturnType<typeof buildConfigDocBaseline>>> | null =
|
||||
null;
|
||||
let sharedRenderedPromise: Promise<
|
||||
Awaited<ReturnType<typeof renderConfigDocBaselineStatefile>>
|
||||
> | 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);
|
||||
});
|
||||
});
|
||||
@ -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<Awaited<ReturnType<typeof buildConfigDocBaseline>>> | null =
|
||||
null;
|
||||
let sharedRenderedPromise: Promise<
|
||||
Awaited<ReturnType<typeof renderConfigDocBaselineStatefile>>
|
||||
> | 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<ChannelPlugin> {
|
||||
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<string, unknown>;
|
||||
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<ChannelSurfaceMetadata | null> {
|
||||
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<string, unknown>;
|
||||
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<ChannelSurfaceMetadata> {
|
||||
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<ConfigSchemaResponse> {
|
||||
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<ConfigSchemaResponse>
|
||||
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<Promise<ChannelSurfaceMetadata[]>>(
|
||||
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<Promise<ChannelSurfaceMetadata[]>>(
|
||||
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<ConfigSchemaResponse>
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChannelSurfaceMetadata(
|
||||
rootDir: string,
|
||||
id: string,
|
||||
label: string,
|
||||
repoRoot: string,
|
||||
): Promise<ChannelSurfaceMetadata | null> {
|
||||
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"],
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import { mergeMissing } from "./legacy.shared.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@ -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<T>(raw: T): T {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return normalizeLegacyWebSearchConfigRecord(raw).config;
|
||||
}
|
||||
|
||||
export function migrateLegacyWebSearchConfig<T>(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<T extends JsonRecord>(
|
||||
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<T>(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(
|
||||
|
||||
@ -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,
|
||||
|
||||
98
src/plugins/loader.git-path-regression.test.ts
Normal file
98
src/plugins/loader.git-path-regression.test.ts
Normal file
@ -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<CreateJiti> | 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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<CreateJiti> | 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({
|
||||
|
||||
129
src/plugins/web-search-providers.runtime.test.ts
Normal file
129
src/plugins/web-search-providers.runtime.test.ts
Normal file
@ -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<string, unknown> } }) => {
|
||||
const plugins = params?.config?.plugins as
|
||||
| {
|
||||
enabled?: boolean;
|
||||
allow?: string[];
|
||||
entries?: Record<string, { enabled?: boolean }>;
|
||||
}
|
||||
| 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();
|
||||
});
|
||||
});
|
||||
56
src/plugins/web-search-providers.runtime.ts
Normal file
56
src/plugins/web-search-providers.runtime.ts
Normal file
@ -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);
|
||||
}
|
||||
120
src/plugins/web-search-providers.shared.ts
Normal file
120
src/plugins/web-search-providers.shared.ts
Normal file
@ -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),
|
||||
};
|
||||
}
|
||||
@ -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<string, unknown> } }) => {
|
||||
const plugins = params?.config?.plugins as
|
||||
| {
|
||||
enabled?: boolean;
|
||||
allow?: string[];
|
||||
entries?: Record<string, { enabled?: boolean }>;
|
||||
}
|
||||
| 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
|
||||
188
src/shared/assistant-error-format.ts
Normal file
188
src/shared/assistant-error-format.ts
Normal file
@ -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*(?:<!doctype\s+html\b|<html\b)/i;
|
||||
const CLOUDFLARE_HTML_ERROR_CODES = new Set([521, 522, 523, 524, 525, 526, 530]);
|
||||
|
||||
type ErrorPayload = Record<string, unknown>;
|
||||
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user