mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 23:13:42 +08:00
Compare commits
188 Commits
codex/mark
...
codex/prot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
896def06ad | ||
|
|
36bf5505d0 | ||
|
|
75a308fbb0 | ||
|
|
30348b6f2d | ||
|
|
c23a828ab7 | ||
|
|
3f9783be5a | ||
|
|
e51c91d0cc | ||
|
|
9b9d8f9763 | ||
|
|
356138633b | ||
|
|
48c0f0772d | ||
|
|
0de186815e | ||
|
|
7411d1cdbd | ||
|
|
f07f7bd193 | ||
|
|
e7e0205a3a | ||
|
|
7c01647400 | ||
|
|
e9b28cea1b | ||
|
|
a388be65eb | ||
|
|
b2c3077a67 | ||
|
|
a1c55315bd | ||
|
|
11c32af56f | ||
|
|
28eb7222e6 | ||
|
|
219ce859e3 | ||
|
|
65a8679bba | ||
|
|
d0a2f56c95 | ||
|
|
a4f7c06ec0 | ||
|
|
4cafd4059e | ||
|
|
0720fcb63a | ||
|
|
7b8fa1a253 | ||
|
|
e48800d4f4 | ||
|
|
ea6224323a | ||
|
|
3f2ebc8c66 | ||
|
|
6928678dd6 | ||
|
|
e1be05bb9e | ||
|
|
c9704d6e14 | ||
|
|
2f85f2c629 | ||
|
|
deb2b6f2fd | ||
|
|
0d771204dc | ||
|
|
7fec279d39 | ||
|
|
9e16ab92b2 | ||
|
|
3a05e5cc7f | ||
|
|
457eb83f8f | ||
|
|
08b5ac1d6d | ||
|
|
6d8df8718c | ||
|
|
3399374f38 | ||
|
|
88184e2bd5 | ||
|
|
ea2634c24e | ||
|
|
baa1167fbe | ||
|
|
8c939e677e | ||
|
|
736db3abae | ||
|
|
8026fc9a61 | ||
|
|
9b2ed68549 | ||
|
|
016199ff3e | ||
|
|
dfdbfa150c | ||
|
|
96f4a37666 | ||
|
|
ba85291383 | ||
|
|
e7518ed99a | ||
|
|
c41d189ef8 | ||
|
|
3a91ee83b3 | ||
|
|
bf32ca7fbf | ||
|
|
76b1cff07a | ||
|
|
6bdade887c | ||
|
|
491027c9da | ||
|
|
bfcc40cbc7 | ||
|
|
bd327672a9 | ||
|
|
80ef5f5994 | ||
|
|
cfeb3cc2c5 | ||
|
|
1934921d87 | ||
|
|
1245a34e4e | ||
|
|
c8d96667c9 | ||
|
|
329aff4dba | ||
|
|
421796e55d | ||
|
|
b8308fc347 | ||
|
|
355cbc5071 | ||
|
|
b4dfa950b5 | ||
|
|
2d61521bd3 | ||
|
|
30b9e123b8 | ||
|
|
a14be505ff | ||
|
|
2c48dd2277 | ||
|
|
4a285d529a | ||
|
|
07821e4bb8 | ||
|
|
4bae78858f | ||
|
|
1db2c2a3e0 | ||
|
|
eb417bc672 | ||
|
|
5d6216a7f1 | ||
|
|
bce3d5bf92 | ||
|
|
ad9f7f9a59 | ||
|
|
a25338f2b7 | ||
|
|
6997453098 | ||
|
|
c35fda3cfa | ||
|
|
8ea6b5d5b2 | ||
|
|
a02a7aaddb | ||
|
|
19fb9f1299 | ||
|
|
2664f59519 | ||
|
|
1cca70940c | ||
|
|
43d0aaec3d | ||
|
|
5487855815 | ||
|
|
45f7aec156 | ||
|
|
286c8e3632 | ||
|
|
e24582d53c | ||
|
|
3e9b197bd0 | ||
|
|
601ab84f35 | ||
|
|
abc3fa0396 | ||
|
|
db576c4a2d | ||
|
|
5e52a9b513 | ||
|
|
3d7523b618 | ||
|
|
afbf895af0 | ||
|
|
af9bad9fe7 | ||
|
|
3995d57797 | ||
|
|
dcf21ac3ad | ||
|
|
e128efa13a | ||
|
|
7f1c991e44 | ||
|
|
a682e64813 | ||
|
|
e31f351923 | ||
|
|
5f505236a6 | ||
|
|
3d1ec37129 | ||
|
|
6c8e065e3b | ||
|
|
cd3887c28a | ||
|
|
92d363773e | ||
|
|
4d3411349b | ||
|
|
5912b9e738 | ||
|
|
64d01ff8a8 | ||
|
|
06f973dd4f | ||
|
|
0552ec899f | ||
|
|
f37ce4ed9b | ||
|
|
20e0d068a7 | ||
|
|
c0400397df | ||
|
|
732d6972d7 | ||
|
|
438eb26d39 | ||
|
|
fd1e314e59 | ||
|
|
a4b4fed412 | ||
|
|
5be282e459 | ||
|
|
4df832412e | ||
|
|
3901f48b0e | ||
|
|
85d2dd8ed2 | ||
|
|
46bd5ebd11 | ||
|
|
5c93de3e7f | ||
|
|
b579c0a65b | ||
|
|
94adfc8d10 | ||
|
|
6883351085 | ||
|
|
93fd17447a | ||
|
|
ebf20241bd | ||
|
|
16808524cb | ||
|
|
58de2b689f | ||
|
|
55467f0b94 | ||
|
|
6ba25c10dc | ||
|
|
3419cf5a0d | ||
|
|
265926aa47 | ||
|
|
63ed9adfe9 | ||
|
|
e6b5083660 | ||
|
|
6349af6502 | ||
|
|
ffbd02fe8e | ||
|
|
75bc80bb42 | ||
|
|
1e7a0d8987 | ||
|
|
39f319c7a4 | ||
|
|
7c4fb1bd2c | ||
|
|
7d5d62511f | ||
|
|
cc6a6f5682 | ||
|
|
7a8d307bdc | ||
|
|
b7d363cadf | ||
|
|
68b4dd1816 | ||
|
|
0e16e72091 | ||
|
|
9ead0ae921 | ||
|
|
3128ec9858 | ||
|
|
1ec291c682 | ||
|
|
9d9a6140a3 | ||
|
|
674bd6fc93 | ||
|
|
b2a55a282a | ||
|
|
3cf4c1ad69 | ||
|
|
fa9ce6ea0e | ||
|
|
0f1f1a1fd7 | ||
|
|
d944aaa9ec | ||
|
|
baade28397 | ||
|
|
883c0f1254 | ||
|
|
793ab78ebb | ||
|
|
57ea5aff81 | ||
|
|
f1d65b3cd6 | ||
|
|
e6b951a6a6 | ||
|
|
55e9194a4c | ||
|
|
8929838159 | ||
|
|
a355c8897d | ||
|
|
b06dc17537 | ||
|
|
7967a3582c | ||
|
|
2e6016fdec | ||
|
|
8a1a8ea8a3 | ||
|
|
4608f7dcf9 | ||
|
|
49ac93bda6 | ||
|
|
f6653b9b35 | ||
|
|
2f92fddef0 |
6
.github/workflows/crabbox-hydrate.yml
vendored
6
.github/workflows/crabbox-hydrate.yml
vendored
@@ -32,11 +32,11 @@ permissions:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_CHILD_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_MODULES_DIR: "/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_MODULES_DIR: "/var/tmp/openclaw-pnpm-node-modules"
|
||||
PNPM_CONFIG_NETWORK_CONCURRENCY: "1"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_STORE_DIR: "/var/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/tmp/openclaw-pnpm-virtual-store"
|
||||
PNPM_CONFIG_VIRTUAL_STORE_DIR: "/var/tmp/openclaw-pnpm-virtual-store"
|
||||
|
||||
jobs:
|
||||
hydrate:
|
||||
|
||||
47
.github/workflows/full-release-validation.yml
vendored
47
.github/workflows/full-release-validation.yml
vendored
@@ -229,7 +229,7 @@ jobs:
|
||||
needs: [resolve_target]
|
||||
if: inputs.rerun_group == 'all'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -245,54 +245,11 @@ jobs:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
timeout --kill-after=30s 15m docker build \
|
||||
--target runtime-assets \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
.
|
||||
|
||||
- name: Build and smoke test final Docker runtime image
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image_ref="openclaw-release-runtime-smoke:${TARGET_SHA}"
|
||||
timeout --kill-after=30s 35m docker build \
|
||||
--build-arg OPENCLAW_EXTENSIONS="diagnostics-otel,codex" \
|
||||
-t "${image_ref}" \
|
||||
.
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
test -f /app/src/agents/templates/HEARTBEAT.md
|
||||
temp_root="$(mktemp -d)"
|
||||
trap "rm -rf \"${temp_root}\"" EXIT
|
||||
mkdir -p "${temp_root}/home" "${temp_root}/cwd"
|
||||
cd "${temp_root}/cwd"
|
||||
set +e
|
||||
HOME="${temp_root}/home" \
|
||||
USERPROFILE="${temp_root}/home" \
|
||||
OPENCLAW_HOME="${temp_root}/home" \
|
||||
OPENCLAW_NO_ONBOARD=1 \
|
||||
OPENCLAW_SUPPRESS_NOTES=1 \
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 \
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK=1 \
|
||||
AWS_EC2_METADATA_DISABLED=true \
|
||||
AWS_SHARED_CREDENTIALS_FILE="${temp_root}/home/.aws/credentials" \
|
||||
AWS_CONFIG_FILE="${temp_root}/home/.aws/config" \
|
||||
node /app/openclaw.mjs agent --message "workspace bootstrap smoke" --session-id "workspace-bootstrap-smoke" --local --timeout 1 --json \
|
||||
>"${temp_root}/out.log" 2>&1
|
||||
status="$?"
|
||||
set -e
|
||||
if grep -F "Missing workspace template:" "${temp_root}/out.log"; then
|
||||
cat "${temp_root}/out.log"
|
||||
exit 1
|
||||
fi
|
||||
test -f "${temp_root}/home/.openclaw/workspace/HEARTBEAT.md"
|
||||
if [ "${status}" -ne 0 ]; then
|
||||
cat "${temp_root}/out.log"
|
||||
fi
|
||||
'
|
||||
|
||||
normal_ci:
|
||||
name: Run normal full CI
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -45,6 +45,17 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
|
||||
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
|
||||
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
|
||||
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
|
||||
- Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.
|
||||
- Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.
|
||||
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
|
||||
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
|
||||
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
|
||||
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
|
||||
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
|
||||
- Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.
|
||||
- Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.
|
||||
- Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.
|
||||
|
||||
@@ -218,6 +218,7 @@ Current OpenClaw Android implication:
|
||||
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
|
||||
- The repo now ships this split as Android product flavors:
|
||||
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
|
||||
- Installed-app listing is user controlled. `device.apps` is advertised only after the user enables **Settings > Phone Capabilities > Installed Apps**. The command defaults to launcher-visible apps and does not require `QUERY_ALL_PACKAGES`.
|
||||
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
|
||||
|
||||
Policy links:
|
||||
|
||||
@@ -148,6 +148,7 @@ class MainViewModel(
|
||||
val gatewayBootstrapToken: StateFlow<String> = prefs.gatewayBootstrapToken
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
|
||||
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
|
||||
@@ -299,6 +300,10 @@ class MainViewModel(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
ensureRuntime().setInstalledAppsSharingEnabled(value)
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
ensureRuntime().setNotificationForwardingEnabled(value)
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ class NodeRuntime(
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
|
||||
@@ -245,6 +246,7 @@ class NodeRuntime(
|
||||
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
photosAvailable = { SensitiveFeatureConfig.photosEnabled },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled.value },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
onCanvasA2uiPush = {
|
||||
_canvasA2uiHydrated.value = true
|
||||
@@ -866,6 +868,7 @@ class NodeRuntime(
|
||||
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = prefs.notificationForwardingEnabled
|
||||
val notificationForwardingMode: StateFlow<NotificationPackageFilterMode> =
|
||||
prefs.notificationForwardingMode
|
||||
@@ -1077,6 +1080,12 @@ class NodeRuntime(
|
||||
prefs.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
if (prefs.installedAppsSharingEnabled.value == value) return
|
||||
prefs.setInstalledAppsSharingEnabled(value)
|
||||
refreshNodeSurfaceAfterSharingChange()
|
||||
}
|
||||
|
||||
fun setNotificationForwardingEnabled(value: Boolean) {
|
||||
prefs.setNotificationForwardingEnabled(value)
|
||||
}
|
||||
@@ -1414,6 +1423,11 @@ class NodeRuntime(
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun refreshNodeSurfaceAfterSharingChange() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
private fun connectWithAuth(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
|
||||
@@ -40,11 +40,13 @@ class SecurePrefs(
|
||||
private const val notificationsForwardingMaxEventsPerMinuteKey =
|
||||
"notifications.forwarding.maxEventsPerMinute"
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
|
||||
private const val voiceMicEnabledKey = "voice.micEnabled"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// Non-secret UI/runtime preferences stay readable for migration and backup behavior.
|
||||
private val plainPrefs: SharedPreferences =
|
||||
appContext.getSharedPreferences(plainPrefsName, Context.MODE_PRIVATE)
|
||||
@@ -114,6 +116,10 @@ class SecurePrefs(
|
||||
MutableStateFlow(plainPrefs.getBoolean("canvas.debugStatusEnabled", false))
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = _canvasDebugStatusEnabled
|
||||
|
||||
private val _installedAppsSharingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(installedAppsSharingEnabledKey, false))
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = _installedAppsSharingEnabled
|
||||
|
||||
private val _notificationForwardingEnabled =
|
||||
MutableStateFlow(plainPrefs.getBoolean(notificationsForwardingEnabledKey, defaultNotificationForwardingEnabled))
|
||||
val notificationForwardingEnabled: StateFlow<Boolean> = _notificationForwardingEnabled
|
||||
@@ -252,6 +258,11 @@ class SecurePrefs(
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun setInstalledAppsSharingEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean(installedAppsSharingEnabledKey, value) }
|
||||
_installedAppsSharingEnabled.value = value
|
||||
}
|
||||
|
||||
internal fun getNotificationForwardingPolicy(appPackageName: String): NotificationForwardingPolicy {
|
||||
val modeRaw = plainPrefs.getString(notificationsForwardingModeKey, null)
|
||||
val mode = NotificationPackageFilterMode.fromRawValue(modeRaw)
|
||||
|
||||
@@ -28,6 +28,7 @@ class ConnectionManager(
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val installedAppsSharingEnabled: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
@@ -115,6 +116,7 @@ class ConnectionManager(
|
||||
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
|
||||
motionActivityAvailable = motionActivityAvailable(),
|
||||
motionPedometerAvailable = motionPedometerAvailable(),
|
||||
installedAppsSharingEnabled = installedAppsSharingEnabled(),
|
||||
debugBuild = BuildConfig.DEBUG,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -24,16 +25,121 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.Locale
|
||||
|
||||
private const val DEFAULT_DEVICE_APPS_LIMIT = 100
|
||||
private const val MAX_DEVICE_APPS_LIMIT = 200
|
||||
private const val DEVICE_APPS_SYSTEM_FLAGS =
|
||||
ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
|
||||
internal fun isSystemDeviceApp(appInfo: ApplicationInfo): Boolean =
|
||||
(appInfo.flags and DEVICE_APPS_SYSTEM_FLAGS) != 0
|
||||
|
||||
internal data class DeviceAppEntry(
|
||||
val label: String,
|
||||
val packageName: String,
|
||||
val system: Boolean,
|
||||
val enabled: Boolean,
|
||||
val launchable: Boolean,
|
||||
)
|
||||
|
||||
internal interface DeviceAppSource {
|
||||
fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry>
|
||||
}
|
||||
|
||||
private class AndroidDeviceAppSource(
|
||||
private val appContext: Context,
|
||||
) : DeviceAppSource {
|
||||
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
|
||||
val packageManager = appContext.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN).apply { addCategory(Intent.CATEGORY_LAUNCHER) }
|
||||
val launchablePackages =
|
||||
packageManager
|
||||
.queryIntentActivities(launcherIntent, PackageManager.MATCH_ALL)
|
||||
.asSequence()
|
||||
.mapNotNull {
|
||||
it.activityInfo
|
||||
?.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
}.toSet()
|
||||
|
||||
val appInfos =
|
||||
if (includeNonLaunchable) {
|
||||
packageManager.getInstalledApplications(PackageManager.MATCH_ALL)
|
||||
} else {
|
||||
launchablePackages.mapNotNull { packageName ->
|
||||
runCatching { packageManager.getApplicationInfo(packageName, 0) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
return appInfos
|
||||
.asSequence()
|
||||
.mapNotNull { appInfo ->
|
||||
appInfo.packageName
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { packageName ->
|
||||
val label = packageManager.getApplicationLabel(appInfo).toString().trim()
|
||||
DeviceAppEntry(
|
||||
label = label.ifEmpty { packageName },
|
||||
packageName = packageName,
|
||||
system = isSystemDeviceApp(appInfo),
|
||||
enabled = appInfo.enabled,
|
||||
launchable = packageName in launchablePackages,
|
||||
)
|
||||
}
|
||||
}.distinctBy { it.packageName }
|
||||
.sortedWith(compareBy<DeviceAppEntry> { it.label.lowercase() }.thenBy { it.packageName })
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private data class DeviceAppsRequest(
|
||||
val includeSystem: Boolean,
|
||||
val includeDisabled: Boolean,
|
||||
val includeNonLaunchable: Boolean,
|
||||
val query: String?,
|
||||
val limit: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Gateway device command adapter for Android status, info, permission, and health snapshots.
|
||||
*/
|
||||
class DeviceHandler(
|
||||
class DeviceHandler private constructor(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
private val appSource: DeviceAppSource = AndroidDeviceAppSource(appContext),
|
||||
) {
|
||||
constructor(
|
||||
appContext: Context,
|
||||
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
) : this(
|
||||
appContext = appContext,
|
||||
smsEnabled = smsEnabled,
|
||||
callLogEnabled = callLogEnabled,
|
||||
photosEnabled = photosEnabled,
|
||||
appSource = AndroidDeviceAppSource(appContext),
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
appContext: Context,
|
||||
appSource: DeviceAppSource,
|
||||
smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
|
||||
): DeviceHandler =
|
||||
DeviceHandler(
|
||||
appContext = appContext,
|
||||
smsEnabled = smsEnabled,
|
||||
callLogEnabled = callLogEnabled,
|
||||
photosEnabled = photosEnabled,
|
||||
appSource = appSource,
|
||||
)
|
||||
|
||||
/**
|
||||
* SMS is available only when the feature flag, telephony hardware, and at least one SMS permission align.
|
||||
*/
|
||||
@@ -74,6 +180,48 @@ class DeviceHandler(
|
||||
/** Returns coarse device health for memory, power, thermal, battery, and security patch state. */
|
||||
fun handleDeviceHealth(_paramsJson: String?): GatewaySession.InvokeResult = GatewaySession.InvokeResult.ok(healthPayloadJson())
|
||||
|
||||
fun handleDeviceApps(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val request = parseDeviceAppsRequest(paramsJson)
|
||||
val matchingApps =
|
||||
appSource
|
||||
.listApps(includeNonLaunchable = request.includeNonLaunchable)
|
||||
.asSequence()
|
||||
.filter { request.includeSystem || !it.system }
|
||||
.filter { request.includeDisabled || it.enabled }
|
||||
.filter { app ->
|
||||
val query = request.query ?: return@filter true
|
||||
app.label.contains(query, ignoreCase = true) || app.packageName.contains(query, ignoreCase = true)
|
||||
}.toList()
|
||||
val limitedApps = matchingApps.take(request.limit)
|
||||
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
buildJsonObject {
|
||||
put("count", JsonPrimitive(limitedApps.size))
|
||||
put("totalMatched", JsonPrimitive(matchingApps.size))
|
||||
put("truncated", JsonPrimitive(matchingApps.size > limitedApps.size))
|
||||
put("visibility", JsonPrimitive(if (request.includeNonLaunchable) "android-visible" else "launcher"))
|
||||
put("includeSystem", JsonPrimitive(request.includeSystem))
|
||||
put("includeDisabled", JsonPrimitive(request.includeDisabled))
|
||||
put(
|
||||
"apps",
|
||||
buildJsonArray {
|
||||
for (app in limitedApps) {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("label", JsonPrimitive(app.label))
|
||||
put("packageName", JsonPrimitive(app.packageName))
|
||||
put("system", JsonPrimitive(app.system))
|
||||
put("enabled", JsonPrimitive(app.enabled))
|
||||
put("launchable", JsonPrimitive(app.launchable))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun statusPayloadJson(): String {
|
||||
val battery = readBatterySnapshot()
|
||||
val powerManager = appContext.getSystemService(PowerManager::class.java)
|
||||
@@ -365,6 +513,24 @@ class DeviceHandler(
|
||||
}.toString()
|
||||
}
|
||||
|
||||
private fun parseDeviceAppsRequest(paramsJson: String?): DeviceAppsRequest {
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val includeSystem = parseJsonBooleanFlag(params, "includeSystem") ?: false
|
||||
val includeDisabled = parseJsonBooleanFlag(params, "includeDisabled") ?: false
|
||||
val includeNonLaunchable = parseJsonBooleanFlag(params, "includeNonLaunchable") ?: false
|
||||
val query = parseJsonString(params, "query")?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val limit =
|
||||
(parseJsonInt(params, "limit") ?: DEFAULT_DEVICE_APPS_LIMIT)
|
||||
.coerceIn(1, MAX_DEVICE_APPS_LIMIT)
|
||||
return DeviceAppsRequest(
|
||||
includeSystem = includeSystem,
|
||||
includeDisabled = includeDisabled,
|
||||
includeNonLaunchable = includeNonLaunchable,
|
||||
query = query,
|
||||
limit = limit,
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBatterySnapshot(): BatterySnapshot {
|
||||
// ACTION_BATTERY_CHANGED is sticky; registerReceiver(null, ...) reads the last system snapshot.
|
||||
val intent = appContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
|
||||
@@ -28,6 +28,7 @@ data class NodeRuntimeFlags(
|
||||
val voiceWakeEnabled: Boolean,
|
||||
val motionActivityAvailable: Boolean,
|
||||
val motionPedometerAvailable: Boolean,
|
||||
val installedAppsSharingEnabled: Boolean,
|
||||
val debugBuild: Boolean,
|
||||
)
|
||||
|
||||
@@ -43,6 +44,7 @@ enum class InvokeCommandAvailability {
|
||||
PhotosAvailable,
|
||||
MotionActivityAvailable,
|
||||
MotionPedometerAvailable,
|
||||
InstalledAppsSharingEnabled,
|
||||
DebugBuild,
|
||||
}
|
||||
|
||||
@@ -193,6 +195,10 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Health.rawValue,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawDeviceCommand.Apps.rawValue,
|
||||
availability = InvokeCommandAvailability.InstalledAppsSharingEnabled,
|
||||
),
|
||||
InvokeCommandSpec(
|
||||
name = OpenClawNotificationsCommand.List.rawValue,
|
||||
),
|
||||
@@ -281,6 +287,7 @@ object InvokeCommandRegistry {
|
||||
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
|
||||
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
|
||||
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
|
||||
InvokeCommandAvailability.InstalledAppsSharingEnabled -> flags.installedAppsSharingEnabled
|
||||
InvokeCommandAvailability.DebugBuild -> flags.debugBuild
|
||||
}
|
||||
}.map { it.name }
|
||||
|
||||
@@ -85,6 +85,7 @@ class InvokeDispatcher(
|
||||
private val smsTelephonyAvailable: () -> Boolean,
|
||||
private val callLogAvailable: () -> Boolean,
|
||||
private val photosAvailable: () -> Boolean,
|
||||
private val installedAppsSharingEnabled: () -> Boolean,
|
||||
private val debugBuild: () -> Boolean,
|
||||
private val onCanvasA2uiPush: () -> Unit,
|
||||
private val onCanvasA2uiReset: () -> Unit,
|
||||
@@ -193,6 +194,7 @@ class InvokeDispatcher(
|
||||
OpenClawDeviceCommand.Info.rawValue -> deviceHandler.handleDeviceInfo(paramsJson)
|
||||
OpenClawDeviceCommand.Permissions.rawValue -> deviceHandler.handleDevicePermissions(paramsJson)
|
||||
OpenClawDeviceCommand.Health.rawValue -> deviceHandler.handleDeviceHealth(paramsJson)
|
||||
OpenClawDeviceCommand.Apps.rawValue -> deviceHandler.handleDeviceApps(paramsJson)
|
||||
|
||||
// Notifications command
|
||||
OpenClawNotificationsCommand.List.rawValue -> notificationsHandler.handleNotificationsList(paramsJson)
|
||||
@@ -348,6 +350,15 @@ class InvokeDispatcher(
|
||||
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.InstalledAppsSharingEnabled ->
|
||||
if (installedAppsSharingEnabled()) {
|
||||
null
|
||||
} else {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INSTALLED_APPS_SHARING_DISABLED",
|
||||
message = "INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
|
||||
)
|
||||
}
|
||||
InvokeCommandAvailability.DebugBuild ->
|
||||
if (debugBuild()) {
|
||||
null
|
||||
|
||||
@@ -112,6 +112,7 @@ enum class OpenClawDeviceCommand(
|
||||
Info("device.info"),
|
||||
Permissions("device.permissions"),
|
||||
Health("device.health"),
|
||||
Apps("device.apps"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -714,6 +714,7 @@ private fun PhoneCapabilitiesScreen(
|
||||
val locationPreciseEnabled by viewModel.locationPreciseEnabled.collectAsState()
|
||||
val preventSleep by viewModel.preventSleep.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val installedAppsSharingEnabled by viewModel.installedAppsSharingEnabled.collectAsState()
|
||||
val cameraPermissionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
viewModel.setCameraEnabled(granted)
|
||||
@@ -768,6 +769,13 @@ private fun PhoneCapabilitiesScreen(
|
||||
listOf(
|
||||
SettingsToggleRow("Camera", "Allow camera tools when requested.", Icons.Default.CameraAlt, cameraEnabled, ::setCameraAccess),
|
||||
SettingsToggleRow("Precise Location", "Share precise location while location is enabled.", Icons.Default.LocationOn, locationPreciseEnabled, ::setPreciseLocation),
|
||||
SettingsToggleRow(
|
||||
"Installed Apps",
|
||||
if (installedAppsSharingEnabled) "OpenClaw can list launcher-visible apps." else "App list stays on this phone.",
|
||||
Icons.Default.Storage,
|
||||
installedAppsSharingEnabled,
|
||||
viewModel::setInstalledAppsSharingEnabled,
|
||||
),
|
||||
SettingsToggleRow("Keep Awake", "Keep the node available during active work.", Icons.Default.Bolt, preventSleep, viewModel::setPreventSleep),
|
||||
SettingsToggleRow("Canvas Status", "Show screen-sharing debug state.", Icons.AutoMirrored.Filled.ScreenShare, canvasDebugStatusEnabled, viewModel::setCanvasDebugStatusEnabled),
|
||||
),
|
||||
|
||||
@@ -62,6 +62,21 @@ class SecurePrefsTest {
|
||||
assertFalse(plainPrefs.getBoolean("talk.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun installedAppsSharing_defaultsOffAndPersistsOptIn() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
assertFalse(prefs.installedAppsSharingEnabled.value)
|
||||
|
||||
prefs.setInstalledAppsSharingEnabled(true)
|
||||
|
||||
assertTrue(prefs.installedAppsSharingEnabled.value)
|
||||
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -9,6 +9,7 @@ import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.app.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.app.protocol.OpenClawPhotosCommand
|
||||
@@ -475,6 +476,15 @@ class ConnectionManagerTest {
|
||||
assertTrue(options.caps.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildNodeConnectOptions_advertisesDeviceAppsOnlyWhenUserOptedIn() {
|
||||
val disabled = newManager(installedAppsSharingEnabled = false).buildNodeConnectOptions()
|
||||
val enabled = newManager(installedAppsSharingEnabled = true).buildNodeConnectOptions()
|
||||
|
||||
assertFalse(disabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
assertTrue(enabled.commands.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildNodeConnectOptions_omitsVoiceWakeWithoutMicrophonePermission() {
|
||||
val options =
|
||||
@@ -546,6 +556,7 @@ class ConnectionManagerTest {
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = false,
|
||||
hasRecordAudioPermission: Boolean = false,
|
||||
installedAppsSharingEnabled: Boolean = false,
|
||||
): ConnectionManager {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val prefs =
|
||||
@@ -567,6 +578,7 @@ class ConnectionManagerTest {
|
||||
callLogAvailable = { callLogAvailable },
|
||||
photosAvailable = { photosAvailable },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled },
|
||||
manualTls = { false },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
@@ -320,6 +321,108 @@ class DeviceHandlerTest {
|
||||
system["securityPatchLevel"]?.jsonPrimitive?.content
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceApps_filtersAndLimitsVisibleApps() {
|
||||
val handler =
|
||||
DeviceHandler.forTesting(
|
||||
appContext = appContext(),
|
||||
appSource =
|
||||
FakeDeviceAppSource(
|
||||
listOf(
|
||||
DeviceAppEntry(
|
||||
label = "Calendar",
|
||||
packageName = "com.google.android.calendar",
|
||||
system = false,
|
||||
enabled = true,
|
||||
launchable = true,
|
||||
),
|
||||
DeviceAppEntry(
|
||||
label = "Android System",
|
||||
packageName = "android",
|
||||
system = true,
|
||||
enabled = true,
|
||||
launchable = false,
|
||||
),
|
||||
DeviceAppEntry(
|
||||
label = "Disabled App",
|
||||
packageName = "com.example.disabled",
|
||||
system = false,
|
||||
enabled = false,
|
||||
launchable = true,
|
||||
),
|
||||
DeviceAppEntry(
|
||||
label = "Gmail",
|
||||
packageName = "com.google.android.gm",
|
||||
system = false,
|
||||
enabled = true,
|
||||
launchable = true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val result = handler.handleDeviceApps("""{"query":"google","limit":1}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
assertEquals("1", payload.getValue("count").jsonPrimitive.content)
|
||||
assertEquals("2", payload.getValue("totalMatched").jsonPrimitive.content)
|
||||
assertTrue(payload.getValue("truncated").jsonPrimitive.boolean)
|
||||
assertEquals("launcher", payload.getValue("visibility").jsonPrimitive.content)
|
||||
val apps = payload.getValue("apps").jsonArray
|
||||
assertEquals(1, apps.size)
|
||||
val app = apps.first().jsonObject
|
||||
assertEquals("Calendar", app.getValue("label").jsonPrimitive.content)
|
||||
assertEquals("com.google.android.calendar", app.getValue("packageName").jsonPrimitive.content)
|
||||
assertTrue(!app.getValue("system").jsonPrimitive.boolean)
|
||||
assertTrue(app.getValue("enabled").jsonPrimitive.boolean)
|
||||
assertTrue(app.getValue("launchable").jsonPrimitive.boolean)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleDeviceApps_canIncludeSystemAndNonLaunchableApps() {
|
||||
val source =
|
||||
FakeDeviceAppSource(
|
||||
listOf(
|
||||
DeviceAppEntry(
|
||||
label = "Android System",
|
||||
packageName = "android",
|
||||
system = true,
|
||||
enabled = true,
|
||||
launchable = false,
|
||||
),
|
||||
),
|
||||
)
|
||||
val handler = DeviceHandler.forTesting(appContext = appContext(), appSource = source)
|
||||
|
||||
val result = handler.handleDeviceApps("""{"includeSystem":true,"includeNonLaunchable":true}""")
|
||||
|
||||
assertTrue(result.ok)
|
||||
val payload = parsePayload(result.payloadJson)
|
||||
assertEquals("android-visible", payload.getValue("visibility").jsonPrimitive.content)
|
||||
assertTrue(payload.getValue("includeSystem").jsonPrimitive.boolean)
|
||||
val app =
|
||||
payload
|
||||
.getValue("apps")
|
||||
.jsonArray
|
||||
.first()
|
||||
.jsonObject
|
||||
assertEquals("android", app.getValue("packageName").jsonPrimitive.content)
|
||||
assertTrue(app.getValue("system").jsonPrimitive.boolean)
|
||||
assertTrue(!app.getValue("launchable").jsonPrimitive.boolean)
|
||||
assertTrue(source.includeNonLaunchableRequests.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isSystemDeviceApp_treatsUpdatedBuiltInsAsSystemApps() {
|
||||
val appInfo =
|
||||
ApplicationInfo().apply {
|
||||
flags = ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||
}
|
||||
|
||||
assertTrue(isSystemDeviceApp(appInfo))
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
|
||||
private fun parsePayload(payloadJson: String?): JsonObject {
|
||||
@@ -327,3 +430,14 @@ class DeviceHandlerTest {
|
||||
return Json.parseToJsonElement(jsonString).jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeDeviceAppSource(
|
||||
private val apps: List<DeviceAppEntry>,
|
||||
) : DeviceAppSource {
|
||||
val includeNonLaunchableRequests = mutableListOf<Boolean>()
|
||||
|
||||
override fun listApps(includeNonLaunchable: Boolean): List<DeviceAppEntry> {
|
||||
includeNonLaunchableRequests += includeNonLaunchable
|
||||
return apps
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,15 @@ class InvokeCommandRegistryTest {
|
||||
assertMissingAll(commands, optionalCommands + debugCommands)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesDeviceAppsOnlyWhenUserOptedIn() {
|
||||
val disabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = false))
|
||||
val enabled = InvokeCommandRegistry.advertisedCommands(defaultFlags(installedAppsSharingEnabled = true))
|
||||
|
||||
assertFalse(disabled.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
assertTrue(enabled.contains(OpenClawDeviceCommand.Apps.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
@@ -151,6 +160,7 @@ class InvokeCommandRegistryTest {
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = false,
|
||||
installedAppsSharingEnabled = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
@@ -262,6 +272,7 @@ class InvokeCommandRegistryTest {
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
installedAppsSharingEnabled: Boolean = false,
|
||||
debugBuild: Boolean = false,
|
||||
): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
@@ -275,6 +286,7 @@ class InvokeCommandRegistryTest {
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
installedAppsSharingEnabled = installedAppsSharingEnabled,
|
||||
debugBuild = debugBuild,
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.app.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.app.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.app.protocol.OpenClawPhotosCommand
|
||||
@@ -170,6 +171,20 @@ class InvokeDispatcherTest {
|
||||
assertEquals("LOCATION_DISABLED: enable Location in Settings", result.error?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleInvoke_blocksDeviceAppsWhenSharingDisabled() =
|
||||
runTest {
|
||||
val result =
|
||||
newDispatcher(installedAppsSharingEnabled = false)
|
||||
.handleInvoke(OpenClawDeviceCommand.Apps.rawValue, """{"limit":1}""")
|
||||
|
||||
assertEquals("INSTALLED_APPS_SHARING_DISABLED", result.error?.code)
|
||||
assertEquals(
|
||||
"INSTALLED_APPS_SHARING_DISABLED: enable Installed Apps in Settings",
|
||||
result.error?.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleInvoke_blocksMotionActivityWhenUnavailable() =
|
||||
runTest {
|
||||
@@ -250,6 +265,7 @@ class InvokeDispatcherTest {
|
||||
smsTelephonyAvailable: Boolean = true,
|
||||
callLogAvailable: Boolean = false,
|
||||
photosAvailable: Boolean = true,
|
||||
installedAppsSharingEnabled: Boolean = true,
|
||||
debugBuild: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
@@ -297,6 +313,7 @@ class InvokeDispatcherTest {
|
||||
smsTelephonyAvailable = { smsTelephonyAvailable },
|
||||
callLogAvailable = { callLogAvailable },
|
||||
photosAvailable = { photosAvailable },
|
||||
installedAppsSharingEnabled = { installedAppsSharingEnabled },
|
||||
debugBuild = { debugBuild },
|
||||
onCanvasA2uiPush = {},
|
||||
onCanvasA2uiReset = {},
|
||||
|
||||
@@ -57,6 +57,7 @@ class OpenClawProtocolConstantsTest {
|
||||
assertEquals("device.info", OpenClawDeviceCommand.Info.rawValue)
|
||||
assertEquals("device.permissions", OpenClawDeviceCommand.Permissions.rawValue)
|
||||
assertEquals("device.health", OpenClawDeviceCommand.Health.rawValue)
|
||||
assertEquals("device.apps", OpenClawDeviceCommand.Apps.rawValue)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -514,12 +514,16 @@ extension GatewayConnection {
|
||||
var params: [String: AnyCodable] = [
|
||||
"message": AnyCodable(trimmed),
|
||||
"sessionKey": AnyCodable(sessionKey),
|
||||
"thinking": AnyCodable(invocation.thinking ?? "default"),
|
||||
"deliver": AnyCodable(invocation.deliver),
|
||||
"to": AnyCodable(invocation.to ?? ""),
|
||||
"channel": AnyCodable(invocation.channel.rawValue),
|
||||
"idempotencyKey": AnyCodable(invocation.idempotencyKey),
|
||||
]
|
||||
if let thinking = invocation.thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!thinking.isEmpty
|
||||
{
|
||||
params["thinking"] = AnyCodable(thinking)
|
||||
}
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
params["timeout"] = AnyCodable(timeout)
|
||||
}
|
||||
@@ -664,7 +668,7 @@ extension GatewayConnection {
|
||||
func chatSend(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
thinking: String?,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload],
|
||||
timeoutMs: Int = 30000) async throws -> OpenClawChatSendResponse
|
||||
@@ -673,10 +677,14 @@ extension GatewayConnection {
|
||||
var params: [String: AnyCodable] = [
|
||||
"sessionKey": AnyCodable(resolvedKey),
|
||||
"message": AnyCodable(message),
|
||||
"thinking": AnyCodable(thinking),
|
||||
"idempotencyKey": AnyCodable(idempotencyKey),
|
||||
"timeoutMs": AnyCodable(timeoutMs),
|
||||
]
|
||||
if let thinking = thinking?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!thinking.isEmpty
|
||||
{
|
||||
params["thinking"] = AnyCodable(thinking)
|
||||
}
|
||||
|
||||
if !attachments.isEmpty {
|
||||
let encoded = attachments.map { att in
|
||||
|
||||
@@ -387,7 +387,7 @@ actor TalkModeRuntime {
|
||||
let response = try await GatewayConnection.shared.chatSend(
|
||||
sessionKey: sessionKey,
|
||||
message: prompt,
|
||||
thinking: "low",
|
||||
thinking: nil,
|
||||
idempotencyKey: runId,
|
||||
attachments: [])
|
||||
guard self.isCurrent(gen) else { return }
|
||||
|
||||
@@ -34,7 +34,7 @@ enum VoiceWakeForwarder {
|
||||
|
||||
struct ForwardOptions {
|
||||
var sessionKey: String = "main"
|
||||
var thinking: String = "low"
|
||||
var thinking: String?
|
||||
var deliver: Bool = true
|
||||
var to: String?
|
||||
var channel: GatewayAgentChannel = .webchat
|
||||
@@ -97,7 +97,6 @@ enum VoiceWakeForwarder {
|
||||
|
||||
return ForwardOptions(
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
to: to,
|
||||
channel: channel,
|
||||
|
||||
@@ -173,9 +173,57 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
let params = json?["params"] as? [String: Any]
|
||||
#expect(params?["thinking"] == nil)
|
||||
#expect(params?["voiceWakeTrigger"] as? String == "")
|
||||
}
|
||||
|
||||
@Test func `chat send omits thinking when inheriting session default`() async throws {
|
||||
let recorder = WebSocketMessageRecorder()
|
||||
let session = GatewayTestWebSocketSession(taskFactory: {
|
||||
GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in
|
||||
recorder.append(message)
|
||||
guard sendIndex > 0,
|
||||
let data = Self.messageData(message),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? String
|
||||
else { return }
|
||||
task.emitReceiveSuccess(.data(Self.chatSendOkResponseData(id: id)))
|
||||
})
|
||||
})
|
||||
let connection = GatewayConnection(
|
||||
configProvider: {
|
||||
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
|
||||
},
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
|
||||
_ = try await connection.chatSend(
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
thinking: nil,
|
||||
idempotencyKey: "chat-1",
|
||||
attachments: [])
|
||||
await connection.shutdown()
|
||||
|
||||
guard let chatMessage = recorder.snapshot().reversed().first(where: { message in
|
||||
guard let data = Self.messageData(message),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
else { return false }
|
||||
return json["method"] as? String == "chat.send"
|
||||
}) else {
|
||||
Issue.record("expected chat.send websocket payload")
|
||||
return
|
||||
}
|
||||
|
||||
guard let payloadData = Self.messageData(chatMessage) else {
|
||||
Issue.record("unexpected chat.send websocket message type")
|
||||
return
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
let params = json?["params"] as? [String: Any]
|
||||
#expect(params?["thinking"] == nil)
|
||||
}
|
||||
|
||||
private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? {
|
||||
switch message {
|
||||
case let .string(text):
|
||||
@@ -186,4 +234,15 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func chatSendOkResponseData(id: String) -> Data {
|
||||
Data("""
|
||||
{
|
||||
"type": "res",
|
||||
"id": "\(id)",
|
||||
"ok": true,
|
||||
"payload": { "runId": "chat-1", "status": "ok" }
|
||||
}
|
||||
""".utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import Testing
|
||||
@Test func `forward options defaults`() {
|
||||
let opts = VoiceWakeForwarder.ForwardOptions()
|
||||
#expect(opts.sessionKey == "main")
|
||||
#expect(opts.thinking == "low")
|
||||
#expect(opts.thinking == nil)
|
||||
#expect(opts.deliver == true)
|
||||
#expect(opts.to == nil)
|
||||
#expect(opts.channel == .webchat)
|
||||
@@ -38,6 +38,7 @@ import Testing
|
||||
#expect(opts.channel == .telegram)
|
||||
#expect(opts.to == "telegram:6812765697")
|
||||
#expect(opts.voiceWakeTrigger == "open claw")
|
||||
#expect(opts.thinking == nil)
|
||||
#expect(opts.channel.shouldDeliver(opts.deliver) == true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
|
||||
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl
|
||||
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
|
||||
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
82
docs/clawhub/cli.md
Normal file
82
docs/clawhub/cli.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
summary: "ClawHub CLI entry points for discovering, installing, publishing, and verifying OpenClaw skills and plugins."
|
||||
read_when:
|
||||
- You want to use ClawHub from the command line
|
||||
- You want to install ClawHub skills or plugins through OpenClaw
|
||||
- You want to publish ClawHub packages
|
||||
title: "ClawHub CLI"
|
||||
---
|
||||
|
||||
# ClawHub CLI
|
||||
|
||||
OpenClaw has two command-line entry points for ClawHub:
|
||||
|
||||
- `openclaw skills` and `openclaw plugins` install and manage ClawHub packages
|
||||
inside OpenClaw.
|
||||
- The standalone `clawhub` CLI handles publisher workflows such as login,
|
||||
publish, transfer, and sync.
|
||||
|
||||
## Discover and install
|
||||
|
||||
Use OpenClaw commands when you want to install or update packages for a local
|
||||
OpenClaw agent or Gateway.
|
||||
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills install <slug>
|
||||
openclaw skills update <slug>
|
||||
openclaw skills verify <slug>
|
||||
|
||||
openclaw plugins search "calendar"
|
||||
openclaw plugins install clawhub:<package>
|
||||
openclaw plugins update <id-or-npm-spec>
|
||||
```
|
||||
|
||||
Skill installs target the active workspace `skills/` directory by default. Add
|
||||
`--global` to install into the shared managed skills directory.
|
||||
|
||||
Plugin installs use the `clawhub:` prefix when you want ClawHub resolution
|
||||
instead of npm or another install source.
|
||||
|
||||
## Publish and maintain
|
||||
|
||||
Install the standalone ClawHub CLI for publisher workflows:
|
||||
|
||||
```bash
|
||||
npm i -g clawhub
|
||||
clawhub login
|
||||
```
|
||||
|
||||
Publish plugin packages with `clawhub package publish`:
|
||||
|
||||
```bash
|
||||
clawhub package publish your-org/your-plugin --dry-run
|
||||
clawhub package publish your-org/your-plugin
|
||||
clawhub package publish your-org/your-plugin@v1.0.0
|
||||
```
|
||||
|
||||
Publish skill folders with `clawhub skill publish`:
|
||||
|
||||
```bash
|
||||
clawhub skill publish ./skills/review-helper
|
||||
clawhub skill publish ./skills/review-helper --version 1.0.0
|
||||
```
|
||||
|
||||
When local skill scan state or package ownership needs maintenance, use the
|
||||
relevant standalone command:
|
||||
|
||||
```bash
|
||||
clawhub sync --all
|
||||
clawhub package transfer @old-owner/package --to new-owner
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [`openclaw skills`](/cli/skills) - local skill search, install, update, and
|
||||
verification
|
||||
- [`openclaw plugins`](/cli/plugins) - plugin search, install, update, and
|
||||
inspection
|
||||
- [ClawHub publishing](/clawhub/publishing) - owner scope, release validation,
|
||||
and review flow
|
||||
- [Creating skills](/tools/creating-skills) - skill authoring and publish flow
|
||||
- [Building plugins](/plugins/building-plugins) - plugin package authoring
|
||||
@@ -93,6 +93,7 @@ openclaw onboard --non-interactive \
|
||||
|
||||
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||
OpenClaw marks common vision model IDs as image-capable automatically. Pass `--custom-image-input` for unknown custom vision IDs, or `--custom-text-input` to force text-only metadata.
|
||||
Use `--custom-compatibility openai-responses` for OpenAI-compatible endpoints that support `/v1/responses` but not `/v1/chat/completions`.
|
||||
|
||||
LM Studio also supports a provider-specific key flag in non-interactive mode:
|
||||
|
||||
|
||||
@@ -372,6 +372,30 @@ its own control markers and channel delivery.
|
||||
For CLIs that emit Claude Code stream-json compatible JSONL, set
|
||||
`jsonlDialect: "claude-stream-json"` on that backend's config.
|
||||
|
||||
## Native compaction ownership
|
||||
|
||||
Some CLI backends run an agent that compacts its **own** transcript, so OpenClaw must
|
||||
not run its safeguard summarizer against them - doing so fights the backend's own
|
||||
compaction and can hard-fail the turn.
|
||||
|
||||
`claude-cli` has no harness endpoint - Claude Code compacts internally - so it declares
|
||||
`ownsNativeCompaction: true`, and OpenClaw returns a no-op from the compaction path.
|
||||
Native-harness sessions such as Codex keep routing to their harness compaction endpoint
|
||||
instead.
|
||||
|
||||
Because the backend owns compaction, the old stopgap of setting
|
||||
`contextTokens: 1_000_000` purely to keep OpenClaw's safeguard from firing on a
|
||||
claude-cli session is **no longer needed** - the opt-out replaces it.
|
||||
|
||||
```typescript
|
||||
api.registerCliBackend({ id: "my-cli", ownsNativeCompaction: true /* ... */ });
|
||||
```
|
||||
|
||||
Only declare `ownsNativeCompaction` for a backend that genuinely owns its compaction: it
|
||||
must reliably bound its own transcript as it nears its context window and persist a
|
||||
resumable session (e.g. `--resume` / `--session-id`); otherwise a deferred session can
|
||||
stay over budget. Matching `agentHarnessId` sessions still route to the harness endpoint.
|
||||
|
||||
## Bundle MCP overlays
|
||||
|
||||
CLI backends do **not** receive OpenClaw tool calls directly, but a backend can
|
||||
|
||||
@@ -469,7 +469,7 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
|
||||
|
||||
- `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
|
||||
- `allowAgents`: default allowlist of configured target agent ids for `sessions_spawn` when the requester agent does not set its own `subagents.allowAgents` (`["*"]` = any configured target; default: same agent only). Stale entries whose agent config was deleted are rejected by `sessions_spawn` and omitted from `agents_list`; run `openclaw doctor --fix` to clean them up.
|
||||
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn` when the tool call omits `runTimeoutSeconds`. `0` means no timeout.
|
||||
- `runTimeoutSeconds`: default timeout (seconds) for `sessions_spawn`. `0` means no timeout.
|
||||
- `announceTimeoutMs`: per-call timeout (milliseconds) for gateway `agent` announce delivery attempts. Default: `120000`. Transient retries can make the total announce wait longer than one configured timeout.
|
||||
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ Android nodes can advertise additional command families when the corresponding c
|
||||
Available families:
|
||||
|
||||
- `device.status`, `device.info`, `device.permissions`, `device.health`
|
||||
- `device.apps` when Installed Apps sharing is enabled in Android Settings
|
||||
- `notifications.list`, `notifications.actions`
|
||||
- `photos.latest`
|
||||
- `contacts.search`, `contacts.add`
|
||||
@@ -341,12 +342,14 @@ Example invokes:
|
||||
|
||||
```bash
|
||||
openclaw nodes invoke --node <idOrNameOrIp> --command device.status --params '{}'
|
||||
openclaw nodes invoke --node <idOrNameOrIp> --command device.apps --params '{"limit":10}'
|
||||
openclaw nodes invoke --node <idOrNameOrIp> --command notifications.list --params '{}'
|
||||
openclaw nodes invoke --node <idOrNameOrIp> --command photos.latest --params '{"limit":1}'
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `device.apps` is opt-in and returns launcher-visible apps by default.
|
||||
- Motion commands are capability-gated by available sensors.
|
||||
|
||||
## System commands (node host / mac node)
|
||||
|
||||
@@ -219,8 +219,9 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
|
||||
- By default, Android Talk uses native speech recognition, Gateway chat, and `talk.speak` through the configured gateway Talk provider. Local system TTS is used only when `talk.speak` is unavailable.
|
||||
- Android Talk uses realtime Gateway relay only when `talk.realtime.mode` is `realtime` and `talk.realtime.transport` is `gateway-relay`.
|
||||
- Voice wake remains disabled in the Android UX/runtime.
|
||||
- Additional Android command families (availability depends on device + permissions):
|
||||
- Additional Android command families (availability depends on device, permissions, and user settings):
|
||||
- `device.status`, `device.info`, `device.permissions`, `device.health`
|
||||
- `device.apps` only when **Settings > Phone Capabilities > Installed Apps** is enabled; it lists launcher-visible apps by default.
|
||||
- `notifications.list`, `notifications.actions` (see [Notification forwarding](#notification-forwarding) below)
|
||||
- `photos.latest`
|
||||
- `contacts.search`, `contacts.add`
|
||||
|
||||
@@ -208,10 +208,28 @@ only for behavior that really belongs to the backend.
|
||||
| `authEpochMode` | Decide how auth changes invalidate stored CLI sessions |
|
||||
| `nativeToolMode` | Declare whether the CLI has always-on native tools |
|
||||
| `bundleMcp` / `bundleMcpMode` | Opt into OpenClaw's loopback MCP tool bridge |
|
||||
| `ownsNativeCompaction` | Backend owns its own compaction - OpenClaw defers |
|
||||
|
||||
Keep these hooks provider-owned. Do not add CLI-specific branches to core when a
|
||||
backend hook can express the behavior.
|
||||
|
||||
### `ownsNativeCompaction`: opting out of OpenClaw compaction
|
||||
|
||||
If your backend runs an agent that compacts its **own** transcript, set
|
||||
`ownsNativeCompaction: true` so OpenClaw's safeguard summarizer never runs against its
|
||||
sessions - the CLI compaction lifecycle returns a no-op and the turn proceeds. `claude-cli`
|
||||
declares it because Claude Code compacts internally with no harness endpoint. Native-harness
|
||||
sessions such as Codex keep routing to their harness compaction endpoint instead.
|
||||
|
||||
**Only declare it when all of the following hold**, or a deferred over-budget session can
|
||||
stay over budget / go stale (OpenClaw no longer rescues it):
|
||||
|
||||
- the backend reliably compacts or bounds its own transcript as it nears its window;
|
||||
- it persists a resumable session so the compacted state survives turns
|
||||
(e.g. `--resume` / `--session-id`);
|
||||
- it is not a native-harness compaction session - matching `agentHarnessId` sessions
|
||||
route to the harness endpoint instead.
|
||||
|
||||
## MCP tool bridge
|
||||
|
||||
CLI backends do not receive OpenClaw tools by default. If the CLI can consume an
|
||||
|
||||
@@ -58,6 +58,15 @@ explicitly to use Gemini, Voyage, Mistral, DeepInfra, Bedrock, GitHub Copilot,
|
||||
Ollama, a local GGUF model, or an OpenAI-compatible `/v1/embeddings` endpoint.
|
||||
Legacy configs that still say `provider: "auto"` resolve to `openai`.
|
||||
|
||||
<Warning>
|
||||
Changing the embedding provider, model, provider settings, sources, scope,
|
||||
chunking, or tokenizer can make the existing SQLite vector index incompatible.
|
||||
OpenClaw pauses vector search and reports an index identity warning instead of
|
||||
automatically re-embedding everything. Rebuild when you are ready with
|
||||
`openclaw memory status --index --agent <id>` or
|
||||
`openclaw memory index --force --agent <id>`.
|
||||
</Warning>
|
||||
|
||||
If OpenAI embeddings are unreachable from your network, memory recall fails open
|
||||
instead of blocking the turn. Set the existing `memorySearch.provider` field to a
|
||||
reachable local, Ollama, regional, or OpenAI-compatible provider to restore
|
||||
@@ -155,7 +164,8 @@ Use `provider: "openai-compatible"` for a generic OpenAI-compatible
|
||||
| `outputDimensionality` | `number` | `3072` | For Embedding 2: 768, 1536, or 3072 |
|
||||
|
||||
<Warning>
|
||||
Changing model or `outputDimensionality` triggers an automatic full reindex.
|
||||
Changing model or `outputDimensionality` changes the index identity. OpenClaw
|
||||
pauses vector search until you explicitly rebuild the memory index.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -219,7 +219,7 @@ What you set:
|
||||
- `--custom-model-id`
|
||||
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
|
||||
- `--custom-provider-id` (optional)
|
||||
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
|
||||
- `--custom-compatibility <openai|openai-responses|anthropic>` (optional; default `openai`)
|
||||
- `--custom-image-input` / `--custom-text-input` (optional; override inferred model input capability)
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -286,8 +286,9 @@ different operation limit:
|
||||
openclaw config set plugins.entries.acpx.config.timeoutSeconds 180
|
||||
```
|
||||
|
||||
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout` and
|
||||
`sessions_spawn.timeoutSeconds`. Restart the gateway after changing this value.
|
||||
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout`.
|
||||
`sessions_spawn` does not accept per-call timeout overrides. Restart the
|
||||
gateway after changing this value.
|
||||
|
||||
### Health probe agent configuration
|
||||
|
||||
|
||||
@@ -549,12 +549,11 @@ Two ways to start an ACP session:
|
||||
`streamLogPath` pointing to a session-scoped JSONL log
|
||||
(`<sessionId>.acp-stream.jsonl`) you can tail for full relay history.
|
||||
</ParamField>
|
||||
<ParamField path="runTimeoutSeconds" type="number">
|
||||
Aborts the ACP child turn after N seconds. `0` keeps the turn on the
|
||||
gateway's no-timeout path. The same value is applied to the Gateway
|
||||
run and ACP runtime so stalled/quota-exhausted harnesses do not
|
||||
occupy the parent agent lane indefinitely.
|
||||
</ParamField>
|
||||
|
||||
ACP `sessions_spawn` runs use `agents.defaults.subagents.runTimeoutSeconds` for
|
||||
their default child turn limit. The tool does not accept per-call timeout
|
||||
overrides.
|
||||
|
||||
<ParamField path="model" type="string">
|
||||
Explicit model override for the ACP child session. Codex ACP spawns
|
||||
normalize OpenAI refs such as `openai/gpt-5.4` to Codex ACP startup
|
||||
|
||||
@@ -141,7 +141,7 @@ session to confirm the effective tool list.
|
||||
|
||||
- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.list[].subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins.
|
||||
- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.list[].subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins.
|
||||
- **Run timeout:** if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout).
|
||||
- **Run timeout:** OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). `sessions_spawn` does not accept per-call timeout overrides.
|
||||
- **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task.
|
||||
|
||||
Accepted native sub-agent spawns include the resolved child model metadata in
|
||||
@@ -208,9 +208,6 @@ Per-agent overrides use `agents.list[].subagents.delegationMode`.
|
||||
<ParamField path="thinking" type="string">
|
||||
Override thinking level for the sub-agent run.
|
||||
</ParamField>
|
||||
<ParamField path="runTimeoutSeconds" type="number">
|
||||
Defaults to `agents.defaults.subagents.runTimeoutSeconds` when set, otherwise `0`. When set, the sub-agent run is aborted after N seconds.
|
||||
</ParamField>
|
||||
<ParamField path="thread" type="boolean" default="false">
|
||||
When `true`, requests channel thread binding for this sub-agent session.
|
||||
</ParamField>
|
||||
@@ -375,7 +372,7 @@ remain spawnable while inheriting defaults.
|
||||
- Archive uses `sessions.delete` and renames the transcript to `*.deleted.<timestamp>` (same folder).
|
||||
- `cleanup: "delete"` archives immediately after announce (still keeps the transcript via rename).
|
||||
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
|
||||
- Configured run timeouts do **not** auto-archive; they only stop the run. The session remains until auto-archive.
|
||||
- Auto-archive applies equally to depth-1 and depth-2 sessions.
|
||||
- Browser cleanup is separate from archive cleanup: tracked browser tabs/processes are best-effort closed when the run finishes, even if the transcript/session record is kept.
|
||||
|
||||
@@ -394,7 +391,7 @@ worker sub-sub-agents.
|
||||
maxSpawnDepth: 2, // allow sub-agents to spawn children (default: 1)
|
||||
maxChildrenPerAgent: 5, // max active children per agent session (default: 5)
|
||||
maxConcurrent: 8, // global concurrency lane cap (default: 8)
|
||||
runTimeoutSeconds: 900, // default timeout for sessions_spawn when omitted (0 = no timeout)
|
||||
runTimeoutSeconds: 900, // default timeout for sessions_spawn (0 = no timeout)
|
||||
announceTimeoutMs: 120000, // per-call gateway announce timeout
|
||||
},
|
||||
},
|
||||
|
||||
@@ -215,6 +215,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
model: "gpt-5.4",
|
||||
sessionOptions: { model: "gpt-5.4" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -619,7 +620,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not normalize model startup for non-Codex ACP agents", async () => {
|
||||
it("passes model startup through sessionOptions for non-Codex ACP agents", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
@@ -648,6 +649,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
agent: "main",
|
||||
mode: "persistent",
|
||||
model: "openai/gpt-5.5",
|
||||
sessionOptions: { model: "openai/gpt-5.5" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -694,6 +696,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
agent: "codex",
|
||||
mode: "persistent",
|
||||
model: "gpt-5.5",
|
||||
sessionOptions: { model: "gpt-5.5" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -728,6 +731,7 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
mode: "persistent",
|
||||
model: "gpt-5.4/xhigh",
|
||||
thinking: "x-high",
|
||||
sessionOptions: { model: "gpt-5.4/xhigh" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type AcpRuntimeStatus,
|
||||
type AcpRuntimeTurn,
|
||||
type AcpRuntimeTurnResult,
|
||||
type SessionAgentOptions,
|
||||
} from "acpx/runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/security-runtime";
|
||||
@@ -49,6 +50,8 @@ type AcpxRuntimeTestOptions = Record<string, unknown> & {
|
||||
openclawProcessCleanup?: AcpxProcessCleanupDeps;
|
||||
};
|
||||
type OpenClawRuntimeTurnInput = Parameters<NonNullable<AcpRuntime["startTurn"]>>[0];
|
||||
type OpenClawRuntimeEnsureInput = Parameters<AcpRuntime["ensureSession"]>[0];
|
||||
type AcpxDelegateEnsureInput = Parameters<BaseAcpxRuntime["ensureSession"]>[0];
|
||||
|
||||
type ResetAwareSessionStore = AcpSessionStore & {
|
||||
markFresh: (sessionKey: string) => void;
|
||||
@@ -547,6 +550,16 @@ function codexAcpSessionModelId(override: CodexAcpModelOverride): string {
|
||||
: override.model;
|
||||
}
|
||||
|
||||
function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegateEnsureInput {
|
||||
const existingOptions = (input as { sessionOptions?: SessionAgentOptions }).sessionOptions;
|
||||
const model = input.model?.trim() || existingOptions?.model;
|
||||
const sessionOptions = model ? { ...existingOptions, model } : existingOptions;
|
||||
return {
|
||||
...input,
|
||||
...(sessionOptions ? { sessionOptions } : {}),
|
||||
} as AcpxDelegateEnsureInput;
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
@@ -942,7 +955,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.withCodexWrapperDiagnostics({
|
||||
command: stableLaunchCommand,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
run: () => delegate.ensureSession(input),
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(input)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -962,7 +975,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.withCodexWrapperDiagnostics({
|
||||
command: stableLaunchCommand,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
run: () => delegate.ensureSession(normalizedInput),
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(normalizedInput)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ export function buildAnthropicCliBackend(): CliBackendPlugin {
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "claude-config-file",
|
||||
nativeToolMode: "always-on",
|
||||
ownsNativeCompaction: true,
|
||||
config: {
|
||||
command: "claude",
|
||||
args: [
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
resolveCodexDynamicToolsLoading,
|
||||
resolveCodexDynamicToolsLoadingForModel,
|
||||
shouldUseDirectCodexDynamicToolsForModel,
|
||||
} from "./dynamic-tool-profile.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
@@ -179,6 +181,22 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
|
||||
});
|
||||
|
||||
it("uses direct dynamic tools for OpenAI nano models without tool_search support", () => {
|
||||
const tools = [createRuntimeDynamicTool("message"), createRuntimeDynamicTool("web_search")];
|
||||
const toolBridge = createCodexDynamicToolBridge({
|
||||
tools,
|
||||
signal: new AbortController().signal,
|
||||
loading: resolveCodexDynamicToolsLoadingForModel({}, "openai/gpt-5.4-nano"),
|
||||
});
|
||||
|
||||
expect(shouldUseDirectCodexDynamicToolsForModel("gpt-5.4-nano")).toBe(true);
|
||||
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.4-nano")).toBe("direct");
|
||||
expect(resolveCodexDynamicToolsLoadingForModel({}, "gpt-5.5")).toBe("searchable");
|
||||
const webSearch = toolBridge.specs.find((tool) => tool.name === "web_search");
|
||||
expect(webSearch).not.toHaveProperty("deferLoading");
|
||||
expect(webSearch).not.toHaveProperty("namespace");
|
||||
});
|
||||
|
||||
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {
|
||||
|
||||
@@ -47,6 +47,33 @@ export function resolveCodexDynamicToolsLoading(
|
||||
: (config.codexDynamicToolsLoading ?? "searchable");
|
||||
}
|
||||
|
||||
function normalizeCodexModelId(modelId: string | undefined): string {
|
||||
const normalized = modelId?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized.includes("/") ? normalized.split("/").at(-1)! : normalized;
|
||||
}
|
||||
|
||||
export function shouldUseDirectCodexDynamicToolsForModel(modelId: string | undefined): boolean {
|
||||
return shouldDisableCodexToolSearchForModel(modelId);
|
||||
}
|
||||
|
||||
export function shouldDisableCodexToolSearchForModel(modelId: string | undefined): boolean {
|
||||
return normalizeCodexModelId(modelId) === "gpt-5.4-nano";
|
||||
}
|
||||
|
||||
export function resolveCodexDynamicToolsLoadingForModel(
|
||||
config: Pick<CodexPluginConfig, "codexDynamicToolsLoading">,
|
||||
modelId: string | undefined,
|
||||
env: CodexDynamicToolProfileEnv = process.env,
|
||||
): CodexDynamicToolsLoading {
|
||||
const loading = resolveCodexDynamicToolsLoading(config, env);
|
||||
return loading === "searchable" && shouldUseDirectCodexDynamicToolsForModel(modelId)
|
||||
? "direct"
|
||||
: loading;
|
||||
}
|
||||
|
||||
export function filterCodexDynamicTools<T extends { name: string }>(
|
||||
tools: T[],
|
||||
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
|
||||
|
||||
@@ -1652,6 +1652,81 @@ describe("CodexAppServerEventProjector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a native tool call finishes without a matching result", async () => {
|
||||
const trajectoryRecorder = {
|
||||
filePath: "trajectory.jsonl",
|
||||
recordEvent: vi.fn(),
|
||||
flush: vi.fn(async () => undefined),
|
||||
};
|
||||
const projector = await createProjector(await createParams(), { trajectoryRecorder });
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/started", {
|
||||
item: {
|
||||
type: "commandExecution",
|
||||
id: "cmd-denied",
|
||||
command: "node scripts/report.js --publish",
|
||||
cwd: "/workspace",
|
||||
processId: null,
|
||||
source: "agent",
|
||||
status: "inProgress",
|
||||
commandActions: [],
|
||||
aggregatedOutput: null,
|
||||
exitCode: null,
|
||||
durationMs: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
turnCompleted([
|
||||
{
|
||||
type: "agentMessage",
|
||||
id: "msg-denied",
|
||||
text: "The requested publish command was denied before execution.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
|
||||
expect(String(result.promptError)).toContain("without a matching tool.result");
|
||||
expect(result.promptErrorSource).toBe("prompt");
|
||||
expect(result.messagesSnapshot.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"toolResult",
|
||||
"assistant",
|
||||
]);
|
||||
const toolResultMessage = requireRecord(result.messagesSnapshot[2], "tool result message");
|
||||
expect(toolResultMessage.toolCallId).toBe("cmd-denied");
|
||||
expect(toolResultMessage.toolName).toBe("bash");
|
||||
expect(toolResultMessage.isError).toBe(true);
|
||||
const toolResultContent = requireArray(toolResultMessage.content, "tool result content");
|
||||
expect(JSON.stringify(toolResultContent)).toContain("matching tool.result");
|
||||
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith("tool.call", {
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
itemId: "cmd-denied",
|
||||
toolCallId: "cmd-denied",
|
||||
name: "bash",
|
||||
arguments: {
|
||||
command: "node scripts/report.js --publish",
|
||||
cwd: "/workspace",
|
||||
},
|
||||
});
|
||||
expect(trajectoryRecorder.recordEvent).toHaveBeenCalledWith("tool.result", {
|
||||
threadId: THREAD_ID,
|
||||
turnId: TURN_ID,
|
||||
itemId: "cmd-denied",
|
||||
toolCallId: "cmd-denied",
|
||||
name: "bash",
|
||||
status: "failed",
|
||||
isError: true,
|
||||
result: { status: "failed", reason: "missing_tool_result" },
|
||||
output: expect.stringContaining("without a matching tool.result"),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses streamed command output when final command snapshots omit aggregated output", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const trajectoryRecorder = {
|
||||
|
||||
@@ -109,6 +109,8 @@ const CODEX_PROMPT_TOTAL_INPUT_KEYS = [
|
||||
|
||||
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
|
||||
const TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS = 12_000;
|
||||
const MISSING_TOOL_RESULT_ERROR =
|
||||
"OpenClaw recorded a native Codex tool.call without a matching tool.result before the turn completed.";
|
||||
const GENERATED_IMAGE_MEDIA_SUBDIR = "tool-image-generation";
|
||||
const BYTES_PER_MB = 1024 * 1024;
|
||||
// Match OpenClaw's default image media cap for generated image tool outputs.
|
||||
@@ -172,6 +174,10 @@ export class CodexAppServerEventProjector {
|
||||
private readonly toolTranscriptMessages: AgentMessage[] = [];
|
||||
private readonly toolTranscriptCallIds = new Set<string>();
|
||||
private readonly toolTranscriptResultIds = new Set<string>();
|
||||
private readonly toolTranscriptNamesById = new Map<string, string>();
|
||||
private readonly toolTrajectoryCallIds = new Set<string>();
|
||||
private readonly toolTrajectoryResultIds = new Set<string>();
|
||||
private readonly toolTrajectoryNamesById = new Map<string, string>();
|
||||
private readonly transcriptToolProgressCallIds = new Set<string>();
|
||||
private lastNativeToolError: EmbeddedRunAttemptResult["lastToolError"];
|
||||
private readonly nativeGeneratedMediaUrls = new Set<string>();
|
||||
@@ -185,6 +191,7 @@ export class CodexAppServerEventProjector {
|
||||
private completedTurn: CodexTurn | undefined;
|
||||
private promptError: unknown;
|
||||
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
|
||||
private synthesizedMissingToolResultError: string | null = null;
|
||||
private aborted = false;
|
||||
private tokenUsage: ReturnType<typeof normalizeUsage>;
|
||||
private guardianReviewCount = 0;
|
||||
@@ -285,6 +292,12 @@ export class CodexAppServerEventProjector {
|
||||
this.reasoningItemOrder,
|
||||
).join("\n\n");
|
||||
const planText = collectTextValues(this.planTextByItem).join("\n\n");
|
||||
this.synthesizeMissingToolResults({
|
||||
failClosed:
|
||||
!this.completedTurn ||
|
||||
this.completedTurn.status !== "completed" ||
|
||||
assistantTexts.length > 0,
|
||||
});
|
||||
const lastAssistant =
|
||||
assistantTexts.length > 0
|
||||
? this.createAssistantMessage(assistantTexts.join("\n\n"))
|
||||
@@ -328,6 +341,7 @@ export class CodexAppServerEventProjector {
|
||||
const turnFailed = this.completedTurn?.status === "failed";
|
||||
const promptError =
|
||||
this.promptError ??
|
||||
this.synthesizedMissingToolResultError ??
|
||||
(turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null);
|
||||
const agentHarnessResultClassification = classifyAgentHarnessTerminalOutcome({
|
||||
assistantTexts,
|
||||
@@ -1125,6 +1139,8 @@ export class CodexAppServerEventProjector {
|
||||
status: ReturnType<typeof itemStatus>;
|
||||
}): void {
|
||||
if (params.phase === "start") {
|
||||
this.toolTrajectoryCallIds.add(params.item.id);
|
||||
this.toolTrajectoryNamesById.set(params.item.id, params.name);
|
||||
this.options.trajectoryRecorder?.recordEvent("tool.call", {
|
||||
threadId: this.threadId,
|
||||
turnId: this.turnId,
|
||||
@@ -1135,6 +1151,7 @@ export class CodexAppServerEventProjector {
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.toolTrajectoryResultIds.add(params.item.id);
|
||||
const toolResult = itemToolResult(params.item).result;
|
||||
const output = itemOutputText(params.item, this.toolResultOutputTextByItem);
|
||||
this.options.trajectoryRecorder?.recordEvent("tool.result", {
|
||||
@@ -1396,6 +1413,7 @@ export class CodexAppServerEventProjector {
|
||||
return;
|
||||
}
|
||||
this.toolTranscriptCallIds.add(params.id);
|
||||
this.toolTranscriptNamesById.set(params.id, params.name);
|
||||
this.toolTranscriptArgumentsById.set(params.id, params.arguments);
|
||||
if (!shouldEmitTranscriptToolProgress(params.name, params.arguments)) {
|
||||
this.transcriptToolProgressSuppressedIds.add(params.id);
|
||||
@@ -1425,6 +1443,61 @@ export class CodexAppServerEventProjector {
|
||||
);
|
||||
}
|
||||
|
||||
private synthesizeMissingToolResults(params: { failClosed: boolean }): void {
|
||||
if (!params.failClosed) {
|
||||
return;
|
||||
}
|
||||
const missingTranscriptIds = [...this.toolTranscriptCallIds].filter(
|
||||
(id) => !this.toolTranscriptResultIds.has(id),
|
||||
);
|
||||
const missingTrajectoryIds = [...this.toolTrajectoryCallIds].filter(
|
||||
(id) => !this.toolTrajectoryResultIds.has(id),
|
||||
);
|
||||
if (missingTranscriptIds.length === 0 && missingTrajectoryIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of missingTranscriptIds) {
|
||||
const name = this.toolTranscriptNamesById.get(id) ?? this.toolTrajectoryNamesById.get(id);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
this.recordToolTranscriptResult({
|
||||
id,
|
||||
name,
|
||||
text: formatMissingToolResultError({ id, name }),
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of missingTrajectoryIds) {
|
||||
const name = this.toolTrajectoryNamesById.get(id) ?? this.toolTranscriptNamesById.get(id);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
this.toolTrajectoryResultIds.add(id);
|
||||
const text = formatMissingToolResultError({ id, name });
|
||||
this.options.trajectoryRecorder?.recordEvent("tool.result", {
|
||||
threadId: this.threadId,
|
||||
turnId: this.turnId,
|
||||
itemId: id,
|
||||
toolCallId: id,
|
||||
name,
|
||||
status: "failed",
|
||||
isError: true,
|
||||
result: { status: "failed", reason: "missing_tool_result" },
|
||||
output: text,
|
||||
});
|
||||
}
|
||||
|
||||
const missingCount = new Set([...missingTranscriptIds, ...missingTrajectoryIds]).size;
|
||||
this.synthesizedMissingToolResultError =
|
||||
missingCount === 1
|
||||
? MISSING_TOOL_RESULT_ERROR
|
||||
: `${MISSING_TOOL_RESULT_ERROR} missingToolResultCount=${missingCount}`;
|
||||
this.promptErrorSource = this.promptErrorSource ?? "prompt";
|
||||
}
|
||||
|
||||
private emitTranscriptToolCallProgress(params: ToolTranscriptCallInput): void {
|
||||
if (!shouldEmitTranscriptToolProgress(params.name, params.arguments)) {
|
||||
return;
|
||||
@@ -1954,6 +2027,10 @@ function itemStatus(item: CodexThreadItem): "completed" | "failed" | "running" |
|
||||
return "completed";
|
||||
}
|
||||
|
||||
function formatMissingToolResultError(params: { id: string; name: string }): string {
|
||||
return `${MISSING_TOOL_RESULT_ERROR} toolCallId=${params.id}; toolName=${params.name}`;
|
||||
}
|
||||
|
||||
function isNonSuccessItemStatus(status: ReturnType<typeof itemStatus>): boolean {
|
||||
return status === "failed" || status === "blocked";
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ import {
|
||||
} from "./dynamic-tool-execution.js";
|
||||
import {
|
||||
filterCodexDynamicTools,
|
||||
resolveCodexDynamicToolsLoading,
|
||||
resolveCodexDynamicToolsLoadingForModel,
|
||||
} from "./dynamic-tool-profile.js";
|
||||
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
|
||||
import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js";
|
||||
@@ -595,7 +595,7 @@ export async function runCodexAppServerAttempt(
|
||||
tools,
|
||||
registeredTools,
|
||||
signal: runAbortController.signal,
|
||||
loading: resolveCodexDynamicToolsLoading(pluginConfig),
|
||||
loading: resolveCodexDynamicToolsLoadingForModel(pluginConfig, params.modelId),
|
||||
directToolNames: shouldForceMessageTool(params) ? ["message"] : [],
|
||||
hookContext: {
|
||||
agentId: sessionAgentId,
|
||||
@@ -2640,7 +2640,7 @@ export const testing = {
|
||||
buildDynamicTools,
|
||||
filterCodexDynamicToolsForAllowlist,
|
||||
includeForcedCodexDynamicToolAllow,
|
||||
resolveCodexDynamicToolsLoading,
|
||||
resolveCodexDynamicToolsLoadingForModel,
|
||||
resolveCodexAppServerHookChannelId,
|
||||
buildCodexAppServerPromptTimeoutOutcome,
|
||||
resolveOpenClawCodingToolsSessionKeys,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
import { sendResult } from "./sandbox-exec-server/json-rpc.js";
|
||||
|
||||
function createSocket() {
|
||||
return {
|
||||
send: vi.fn(),
|
||||
} as unknown as WebSocket & { send: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
function sentJson(socket: ReturnType<typeof createSocket>) {
|
||||
return JSON.parse(String(socket.send.mock.calls[0]?.[0])) as unknown;
|
||||
}
|
||||
|
||||
describe("sandbox exec-server JSON-RPC helpers", () => {
|
||||
it("preserves explicit null results", () => {
|
||||
const socket = createSocket();
|
||||
|
||||
sendResult(socket, 1, null);
|
||||
|
||||
expect(sentJson(socket)).toEqual({ jsonrpc: "2.0", id: 1, result: null });
|
||||
});
|
||||
|
||||
it("keeps undefined results as empty objects for methods without bodies", () => {
|
||||
const socket = createSocket();
|
||||
|
||||
sendResult(socket, 2, undefined);
|
||||
|
||||
expect(sentJson(socket)).toEqual({ jsonrpc: "2.0", id: 2, result: {} });
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,9 @@ export function sendResult(
|
||||
id: string | number,
|
||||
result: JsonValue | undefined,
|
||||
): void {
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
|
||||
socket.send(
|
||||
JSON.stringify({ jsonrpc: "2.0", id, result: result === undefined ? {} : result }),
|
||||
);
|
||||
}
|
||||
|
||||
export function sendError(
|
||||
|
||||
@@ -40,6 +40,7 @@ export type CodexAppServerThreadBinding = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
dynamicToolsFingerprint?: string;
|
||||
dynamicToolsContainDeferred?: boolean;
|
||||
userMcpServersFingerprint?: string;
|
||||
mcpServersFingerprint?: string;
|
||||
nativeHookRelayGeneration?: string;
|
||||
@@ -111,6 +112,10 @@ export async function readCodexAppServerBinding(
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
: undefined,
|
||||
dynamicToolsContainDeferred:
|
||||
typeof parsed.dynamicToolsContainDeferred === "boolean"
|
||||
? parsed.dynamicToolsContainDeferred
|
||||
: undefined,
|
||||
userMcpServersFingerprint:
|
||||
typeof parsed.userMcpServersFingerprint === "string"
|
||||
? parsed.userMcpServersFingerprint
|
||||
@@ -170,6 +175,7 @@ export async function writeCodexAppServerBinding(
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint: binding.userMcpServersFingerprint,
|
||||
mcpServersFingerprint: binding.mcpServersFingerprint,
|
||||
nativeHookRelayGeneration: binding.nativeHookRelayGeneration,
|
||||
|
||||
@@ -63,6 +63,16 @@ function createNamedDynamicTool(
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferredNamedDynamicTool(
|
||||
name: string,
|
||||
): Parameters<typeof startOrResumeThread>[0]["dynamicTools"][number] {
|
||||
return {
|
||||
...createNamedDynamicTool(name),
|
||||
namespace: "openclaw",
|
||||
deferLoading: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginAppConfigPatch() {
|
||||
return {
|
||||
apps: {
|
||||
@@ -243,6 +253,42 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
|
||||
});
|
||||
|
||||
it("starts a fresh Codex thread when dynamic tools switch from deferred to direct", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
const appServer = createThreadLifecycleAppServerOptions();
|
||||
let starts = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
starts += 1;
|
||||
return threadStartResult(`thread-${starts}`);
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createDeferredNamedDynamicTool("web_search")],
|
||||
appServer,
|
||||
});
|
||||
const binding = await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createNamedDynamicTool("web_search")],
|
||||
appServer,
|
||||
});
|
||||
|
||||
expect(binding.threadId).toBe("thread-2");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
|
||||
});
|
||||
|
||||
it("resumes a bound Codex thread when dynamic tools are reordered", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -489,7 +535,7 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
|
||||
dynamicTools: [createDeferredNamedDynamicTool("message")],
|
||||
appServer,
|
||||
});
|
||||
const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint;
|
||||
@@ -504,12 +550,13 @@ describe("Codex app-server thread lifecycle bindings", () => {
|
||||
client: { request } as never,
|
||||
params,
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [createMessageDynamicTool("Send and manage messages.")],
|
||||
dynamicTools: [createDeferredNamedDynamicTool("message")],
|
||||
appServer,
|
||||
});
|
||||
|
||||
const binding = await readCodexAppServerBinding(sessionFile);
|
||||
expect(binding?.dynamicToolsFingerprint).toBe(fingerprint);
|
||||
expect(binding?.dynamicToolsContainDeferred).toBe(true);
|
||||
expect(binding?.threadId).toBe("thread-1");
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual([
|
||||
"thread/start",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import {
|
||||
buildDeveloperInstructions,
|
||||
@@ -8,8 +11,15 @@ import {
|
||||
buildThreadResumeParams,
|
||||
buildThreadStartParams,
|
||||
codexDynamicToolsFingerprint,
|
||||
formatCodexThreadLifecycleTimingSummary,
|
||||
resolveReasoningEffort,
|
||||
shouldWarnCodexThreadLifecycleTimingSummary,
|
||||
startOrResumeThread,
|
||||
type CodexThreadLifecycleTimingLogger,
|
||||
} from "./thread-lifecycle.js";
|
||||
import { createCodexTestModel } from "./test-support.js";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
function createAttemptParams(params: {
|
||||
provider: string;
|
||||
@@ -21,6 +31,7 @@ function createAttemptParams(params: {
|
||||
bootstrapContextMode?: "full" | "lightweight";
|
||||
bootstrapContextRunKind?: "default" | "heartbeat" | "cron";
|
||||
images?: EmbeddedRunAttemptParams["images"];
|
||||
modelId?: string;
|
||||
}): EmbeddedRunAttemptParams {
|
||||
const authProfileProviders =
|
||||
params.authProfileProviders ??
|
||||
@@ -30,7 +41,7 @@ function createAttemptParams(params: {
|
||||
const authProfileType = params.authProfileType ?? "oauth";
|
||||
return {
|
||||
provider: params.provider,
|
||||
modelId: "gpt-5.4",
|
||||
modelId: params.modelId ?? "gpt-5.4",
|
||||
prompt: "test prompt",
|
||||
authProfileId: params.authProfileId,
|
||||
...(params.bootstrapContextMode ? { bootstrapContextMode: params.bootstrapContextMode } : {}),
|
||||
@@ -73,6 +84,102 @@ function createAppServerOptions() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createThreadLifecycleParams(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
prompt: "hello",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
runId: "run-1",
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4-codex",
|
||||
model: createCodexTestModel("codex"),
|
||||
thinkLevel: "medium",
|
||||
disableTools: true,
|
||||
timeoutMs: 5_000,
|
||||
authStorage: {} as never,
|
||||
authProfileStore: { version: 1, profiles: {} },
|
||||
modelRegistry: {} as never,
|
||||
} as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
function createThreadLifecycleAppServerOptions(): Parameters<
|
||||
typeof startOrResumeThread
|
||||
>[0]["appServer"] {
|
||||
return {
|
||||
start: {
|
||||
transport: "stdio",
|
||||
command: "codex",
|
||||
args: ["app-server"],
|
||||
headers: {},
|
||||
},
|
||||
codeModeOnly: false,
|
||||
requestTimeoutMs: 60_000,
|
||||
turnCompletionIdleTimeoutMs: 60_000,
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: "workspace-write",
|
||||
};
|
||||
}
|
||||
|
||||
function threadStartResult(threadId = "thread-1") {
|
||||
return {
|
||||
thread: {
|
||||
id: threadId,
|
||||
sessionId: "session-1",
|
||||
forkedFromId: null,
|
||||
preview: "",
|
||||
ephemeral: false,
|
||||
modelProvider: "openai",
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
status: { type: "idle" },
|
||||
path: null,
|
||||
cwd: tempDir,
|
||||
cliVersion: "0.125.0",
|
||||
source: "unknown",
|
||||
agentNickname: null,
|
||||
agentRole: null,
|
||||
gitInfo: null,
|
||||
name: null,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4-codex",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: tempDir,
|
||||
instructionSources: [],
|
||||
approvalPolicy: "never",
|
||||
approvalsReviewer: "user",
|
||||
sandbox: { type: "dangerFullAccess" },
|
||||
permissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createTimingLogger(traceEnabled: boolean): CodexThreadLifecycleTimingLogger {
|
||||
return {
|
||||
isEnabled: vi.fn((level: "trace") => level === "trace" && traceEnabled),
|
||||
trace: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function expectSingleLogMessage(
|
||||
log: CodexThreadLifecycleTimingLogger,
|
||||
level: "trace" | "warn",
|
||||
): string {
|
||||
const mock = log[level] as ReturnType<typeof vi.fn>;
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
const message = mock.mock.calls[0]?.[0];
|
||||
expect(typeof message).toBe("string");
|
||||
return message as string;
|
||||
}
|
||||
|
||||
describe("Codex app-server native code mode config", () => {
|
||||
it("keeps Codex-native subagents primary while limiting OpenClaw spawn to OpenClaw delegation", () => {
|
||||
const instructions = buildDeveloperInstructions(createAttemptParams({ provider: "openai" }));
|
||||
@@ -151,7 +258,7 @@ describe("Codex app-server native code mode config", () => {
|
||||
expect(instructions).not.toContain("Deferred searchable OpenClaw dynamic tools available");
|
||||
});
|
||||
|
||||
it("keeps durable dynamic tool fingerprints independent from presentation mode", () => {
|
||||
it("keeps durable dynamic tool fingerprints scoped to loading mode", () => {
|
||||
const inputSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -177,7 +284,7 @@ describe("Codex app-server native code mode config", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(searchableFingerprint).toBe(directFingerprint);
|
||||
expect(searchableFingerprint).not.toBe(directFingerprint);
|
||||
});
|
||||
|
||||
it("keeps OpenClaw skill catalogs out of developer instructions", () => {
|
||||
@@ -214,6 +321,25 @@ describe("Codex app-server native code mode config", () => {
|
||||
expect(request.personality).toBe("none");
|
||||
});
|
||||
|
||||
it("disables Codex tool-search features for nano models", () => {
|
||||
const request = buildThreadStartParams(
|
||||
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
|
||||
{
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
},
|
||||
);
|
||||
|
||||
expect(request.config).toEqual({
|
||||
"features.code_mode": true,
|
||||
"features.code_mode_only": false,
|
||||
"features.apply_patch_streaming_events": true,
|
||||
"features.multi_agent": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes Codex model personality on thread/resume", () => {
|
||||
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
@@ -674,6 +800,176 @@ describe("Codex app-server model provider selection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex app-server thread lifecycle timing", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-thread-lifecycle-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("formats stage summaries with run, session, action, and elapsed timing", () => {
|
||||
const message = formatCodexThreadLifecycleTimingSummary({
|
||||
runId: "run-a",
|
||||
sessionId: "session-a",
|
||||
sessionKey: "agent:main:session-a",
|
||||
action: "started",
|
||||
summary: {
|
||||
totalMs: 12,
|
||||
spans: [
|
||||
{ name: "read-binding", durationMs: 4, elapsedMs: 4 },
|
||||
{ name: "thread-start-request", durationMs: 8, elapsedMs: 12 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(message).toBe(
|
||||
"[trace:codex-app-server] thread lifecycle: runId=run-a sessionId=session-a " +
|
||||
"sessionKey=agent:main:session-a action=started totalMs=12 " +
|
||||
"stages=read-binding:4ms@4ms,thread-start-request:8ms@12ms",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when the total or a single stage crosses the lifecycle threshold", () => {
|
||||
expect(
|
||||
shouldWarnCodexThreadLifecycleTimingSummary(
|
||||
{
|
||||
totalMs: 9,
|
||||
spans: [{ name: "thread-start-request", durationMs: 10, elapsedMs: 10 }],
|
||||
},
|
||||
{ totalThresholdMs: 50, stageThresholdMs: 10 },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldWarnCodexThreadLifecycleTimingSummary(
|
||||
{
|
||||
totalMs: 50,
|
||||
spans: [{ name: "thread-start-request", durationMs: 1, elapsedMs: 1 }],
|
||||
},
|
||||
{ totalThresholdMs: 50, stageThresholdMs: 10 },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("emits a trace stage summary when starting a new thread with trace enabled", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
let nowMs = 0;
|
||||
const log = createTimingLogger(true);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
nowMs += 17;
|
||||
return threadStartResult("thread-started");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createThreadLifecycleParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
timing: {
|
||||
enabled: true,
|
||||
now: () => nowMs,
|
||||
log,
|
||||
totalThresholdMs: 1_000,
|
||||
stageThresholdMs: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
const message = expectSingleLogMessage(log, "trace");
|
||||
expect(log.warn).not.toHaveBeenCalled();
|
||||
expect(message).toContain("action=started");
|
||||
expect(message).toContain("thread-start-request:17ms@17ms");
|
||||
expect(message).toContain("thread-ready:0ms@17ms");
|
||||
});
|
||||
|
||||
it("emits a trace stage summary when resuming an existing thread", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
let nowMs = 0;
|
||||
const log = createTimingLogger(true);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
nowMs += 9;
|
||||
return threadStartResult("thread-existing");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const commonParams = {
|
||||
client: { request } as never,
|
||||
params: createThreadLifecycleParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
};
|
||||
|
||||
await startOrResumeThread({
|
||||
...commonParams,
|
||||
timing: {
|
||||
enabled: true,
|
||||
now: () => nowMs,
|
||||
log: createTimingLogger(false),
|
||||
},
|
||||
});
|
||||
await startOrResumeThread({
|
||||
...commonParams,
|
||||
timing: {
|
||||
enabled: true,
|
||||
now: () => nowMs,
|
||||
log,
|
||||
totalThresholdMs: 1_000,
|
||||
stageThresholdMs: 1_000,
|
||||
},
|
||||
});
|
||||
|
||||
const message = expectSingleLogMessage(log, "trace");
|
||||
expect(message).toContain("action=resumed");
|
||||
expect(message).toContain("thread-resume-request:9ms@9ms");
|
||||
});
|
||||
|
||||
it("warns on slow start even when trace logging is disabled", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
let nowMs = 0;
|
||||
const log = createTimingLogger(false);
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
nowMs += 25;
|
||||
return threadStartResult("thread-slow");
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createThreadLifecycleParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createThreadLifecycleAppServerOptions(),
|
||||
timing: {
|
||||
enabled: true,
|
||||
now: () => nowMs,
|
||||
log,
|
||||
totalThresholdMs: 10,
|
||||
stageThresholdMs: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const message = expectSingleLogMessage(log, "warn");
|
||||
expect(log.trace).not.toHaveBeenCalled();
|
||||
expect(message).toContain("action=started");
|
||||
expect(message).toContain("thread-start-request:25ms@25ms");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReasoningEffort (#71946)", () => {
|
||||
describe("modern Codex models (none/low/medium/high/xhigh enum)", () => {
|
||||
it.each(["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"] as const)(
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
resolveCodexContextEngineProjectionMaxChars,
|
||||
resolveCodexContextEngineProjectionReserveTokens,
|
||||
} from "./context-engine-projection.js";
|
||||
import { shouldDisableCodexToolSearchForModel } from "./dynamic-tool-profile.js";
|
||||
import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js";
|
||||
import {
|
||||
isCodexPluginThreadBindingStale,
|
||||
@@ -114,32 +115,88 @@ const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = {
|
||||
project_doc_max_bytes: 0,
|
||||
};
|
||||
|
||||
type CodexThreadLifecycleTimingSpan = {
|
||||
const CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG: JsonObject = {
|
||||
"features.multi_agent": false,
|
||||
};
|
||||
|
||||
export type CodexThreadLifecycleTimingSpan = {
|
||||
name: string;
|
||||
durationMs: number;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
type CodexThreadLifecycleTimingSummary = {
|
||||
export type CodexThreadLifecycleTimingSummary = {
|
||||
totalMs: number;
|
||||
spans: CodexThreadLifecycleTimingSpan[];
|
||||
};
|
||||
|
||||
export type CodexThreadLifecycleTimingLogger = {
|
||||
isEnabled?: (level: "trace") => boolean;
|
||||
trace: (message: string, meta?: Record<string, unknown>) => void;
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export type CodexThreadLifecycleTimingAction = "started" | "resumed" | "rotated";
|
||||
|
||||
export type CodexThreadLifecycleTimingOptions = {
|
||||
enabled?: boolean;
|
||||
now?: () => number;
|
||||
log?: CodexThreadLifecycleTimingLogger;
|
||||
totalThresholdMs?: number;
|
||||
stageThresholdMs?: number;
|
||||
};
|
||||
|
||||
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS = 1_000;
|
||||
const CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS = 500;
|
||||
|
||||
function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean } = {}): {
|
||||
export function shouldWarnCodexThreadLifecycleTimingSummary(
|
||||
summary: CodexThreadLifecycleTimingSummary,
|
||||
options: CodexThreadLifecycleTimingOptions = {},
|
||||
): boolean {
|
||||
const totalThresholdMs =
|
||||
options.totalThresholdMs ?? CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS;
|
||||
const stageThresholdMs =
|
||||
options.stageThresholdMs ?? CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS;
|
||||
return (
|
||||
summary.totalMs >= totalThresholdMs ||
|
||||
summary.spans.some((span) => span.durationMs >= stageThresholdMs)
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCodexThreadLifecycleTimingSummary(params: {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
action: CodexThreadLifecycleTimingAction;
|
||||
summary: CodexThreadLifecycleTimingSummary;
|
||||
}): string {
|
||||
const spans =
|
||||
params.summary.spans.length > 0
|
||||
? params.summary.spans
|
||||
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
|
||||
.join(",")
|
||||
: "none";
|
||||
return (
|
||||
`[trace:codex-app-server] thread lifecycle: runId=${params.runId} ` +
|
||||
`sessionId=${params.sessionId} sessionKey=${params.sessionKey ?? "unknown"} ` +
|
||||
`action=${params.action} totalMs=${params.summary.totalMs} stages=${spans}`
|
||||
);
|
||||
}
|
||||
|
||||
function createCodexThreadLifecycleTimingTracker(options: CodexThreadLifecycleTimingOptions = {}): {
|
||||
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
|
||||
measureSync: <T>(name: string, run: () => T) => T;
|
||||
logIfSlow: (params: {
|
||||
mark: (name: string) => void;
|
||||
logSummary: (params: {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
action: "started" | "resumed" | "rotated";
|
||||
action: CodexThreadLifecycleTimingAction;
|
||||
threadId?: string;
|
||||
}) => void;
|
||||
} {
|
||||
if (!options.enabled) {
|
||||
const log = options.log ?? embeddedAgentLog;
|
||||
if (!options.enabled && log.isEnabled?.("trace") !== true) {
|
||||
return {
|
||||
async measure(_name, run) {
|
||||
return await run();
|
||||
@@ -147,37 +204,31 @@ function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean }
|
||||
measureSync(_name, run) {
|
||||
return run();
|
||||
},
|
||||
logIfSlow() {},
|
||||
mark() {},
|
||||
logSummary() {},
|
||||
};
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const now = options.now ?? Date.now;
|
||||
const startedAt = now();
|
||||
let didLog = false;
|
||||
const spans: CodexThreadLifecycleTimingSpan[] = [];
|
||||
const toMs = (value: number) => Math.max(0, Math.round(value));
|
||||
const record = (name: string, spanStartedAt: number) => {
|
||||
const currentAt = now();
|
||||
spans.push({
|
||||
name,
|
||||
durationMs: toMs(Date.now() - spanStartedAt),
|
||||
elapsedMs: toMs(Date.now() - startedAt),
|
||||
durationMs: toMs(currentAt - spanStartedAt),
|
||||
elapsedMs: toMs(currentAt - startedAt),
|
||||
});
|
||||
};
|
||||
const snapshot = (): CodexThreadLifecycleTimingSummary => ({
|
||||
totalMs: toMs(Date.now() - startedAt),
|
||||
totalMs: toMs(now() - startedAt),
|
||||
spans: spans.slice(),
|
||||
});
|
||||
const shouldLog = (summary: CodexThreadLifecycleTimingSummary) =>
|
||||
summary.totalMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_TOTAL_MS ||
|
||||
summary.spans.some((span) => span.durationMs >= CODEX_THREAD_LIFECYCLE_TIMING_WARN_STAGE_MS);
|
||||
const formatSpans = (summary: CodexThreadLifecycleTimingSummary) =>
|
||||
summary.spans.length > 0
|
||||
? summary.spans
|
||||
.map((span) => `${span.name}:${span.durationMs}ms@${span.elapsedMs}ms`)
|
||||
.join(",")
|
||||
: "none";
|
||||
return {
|
||||
async measure(name, run) {
|
||||
const spanStartedAt = Date.now();
|
||||
const spanStartedAt = now();
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
@@ -185,38 +236,47 @@ function createCodexThreadLifecycleTimingTracker(options: { enabled?: boolean }
|
||||
}
|
||||
},
|
||||
measureSync(name, run) {
|
||||
const spanStartedAt = Date.now();
|
||||
const spanStartedAt = now();
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
record(name, spanStartedAt);
|
||||
}
|
||||
},
|
||||
logIfSlow(params) {
|
||||
mark(name) {
|
||||
record(name, now());
|
||||
},
|
||||
logSummary(params) {
|
||||
if (didLog) {
|
||||
return;
|
||||
}
|
||||
const summary = snapshot();
|
||||
if (!shouldLog(summary)) {
|
||||
const shouldWarn = shouldWarnCodexThreadLifecycleTimingSummary(summary, options);
|
||||
if (!shouldWarn && !log.isEnabled?.("trace")) {
|
||||
return;
|
||||
}
|
||||
didLog = true;
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server thread lifecycle timings runId=${params.runId} sessionId=${
|
||||
params.sessionId
|
||||
} sessionKey=${params.sessionKey ?? "unknown"} action=${params.action} totalMs=${
|
||||
summary.totalMs
|
||||
} stages=${formatSpans(summary)}`,
|
||||
{
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
action: params.action,
|
||||
threadId: params.threadId,
|
||||
totalMs: summary.totalMs,
|
||||
spans: summary.spans,
|
||||
},
|
||||
);
|
||||
const message = formatCodexThreadLifecycleTimingSummary({
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
action: params.action,
|
||||
summary,
|
||||
});
|
||||
const meta = {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
action: params.action,
|
||||
threadId: params.threadId,
|
||||
totalMs: summary.totalMs,
|
||||
spans: summary.spans,
|
||||
};
|
||||
if (shouldWarn) {
|
||||
log.warn(message, meta);
|
||||
} else {
|
||||
log.trace(message, meta);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -244,16 +304,22 @@ export async function startOrResumeThread(params: {
|
||||
pluginThreadConfig?: CodexPluginThreadConfigProvider;
|
||||
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
|
||||
signal?: AbortSignal;
|
||||
timing?: CodexThreadLifecycleTimingOptions;
|
||||
}): Promise<CodexAppServerThreadLifecycleBinding> {
|
||||
// Thread lifecycle spans are useful when profiling startup churn, but normal
|
||||
// turns should not pay Date.now/span-array overhead while resuming threads.
|
||||
const lifecycleTiming = createCodexThreadLifecycleTimingTracker({
|
||||
enabled: isCodexAppServerProfilerEnabled(params.params.config),
|
||||
...params.timing,
|
||||
enabled:
|
||||
params.timing?.enabled ?? isCodexAppServerProfilerEnabled(params.params.config),
|
||||
});
|
||||
const dynamicToolsFingerprint = lifecycleTiming.measureSync("fingerprint_dynamic_tools", () =>
|
||||
const dynamicToolsFingerprint = lifecycleTiming.measureSync("dynamic-tools-fingerprint", () =>
|
||||
fingerprintDynamicTools(params.dynamicTools),
|
||||
);
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context_engine_binding", () =>
|
||||
const dynamicToolsContainDeferred = params.dynamicTools.some(
|
||||
(tool) => tool.deferLoading === true,
|
||||
);
|
||||
const contextEngineBinding = lifecycleTiming.measureSync("context-engine-binding", () =>
|
||||
buildContextEngineBinding(params.params, params.contextEngineProjection),
|
||||
);
|
||||
const userMcpServersConfigPatch =
|
||||
@@ -266,7 +332,7 @@ export async function startOrResumeThread(params: {
|
||||
const environmentSelectionFingerprint = fingerprintEnvironmentSelection(
|
||||
params.environmentSelection,
|
||||
);
|
||||
let binding = await lifecycleTiming.measure("read_binding", () =>
|
||||
let binding = await lifecycleTiming.measure("read-binding", () =>
|
||||
readCodexAppServerBinding(params.params.sessionFile, {
|
||||
authProfileStore: params.params.authProfileStore,
|
||||
agentDir: params.params.agentDir,
|
||||
@@ -373,7 +439,7 @@ export async function startOrResumeThread(params: {
|
||||
})
|
||||
) {
|
||||
try {
|
||||
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin_config_recovery", () =>
|
||||
prebuiltPluginThreadConfig = await lifecycleTiming.measure("plugin-config-recovery", () =>
|
||||
params.pluginThreadConfig?.build(),
|
||||
);
|
||||
pluginBindingStale =
|
||||
@@ -404,6 +470,23 @@ export async function startOrResumeThread(params: {
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
if (
|
||||
binding.dynamicToolsFingerprint &&
|
||||
params.dynamicTools.length > 0 &&
|
||||
binding.dynamicToolsContainDeferred !== dynamicToolsContainDeferred &&
|
||||
(binding.dynamicToolsContainDeferred !== undefined || !dynamicToolsContainDeferred)
|
||||
) {
|
||||
embeddedAgentLog.debug(
|
||||
"codex app-server dynamic tool loading changed; starting a new thread",
|
||||
{
|
||||
threadId: binding.threadId,
|
||||
},
|
||||
);
|
||||
await clearCodexAppServerBinding(params.params.sessionFile);
|
||||
binding = undefined;
|
||||
}
|
||||
}
|
||||
if (binding?.threadId) {
|
||||
// `/codex resume <thread>` writes a binding before the next turn can know
|
||||
// the dynamic tool catalog, so only invalidate fingerprints we actually have.
|
||||
@@ -449,7 +532,7 @@ export async function startOrResumeThread(params: {
|
||||
userMcpServersConfigPatch,
|
||||
finalConfigPatch.configPatch,
|
||||
);
|
||||
const resumeParams = lifecycleTiming.measureSync("thread_resume_params", () =>
|
||||
const resumeParams = lifecycleTiming.measureSync("thread-resume-params", () =>
|
||||
buildThreadResumeParams(params.params, {
|
||||
threadId: binding.threadId,
|
||||
authProfileId,
|
||||
@@ -462,7 +545,7 @@ export async function startOrResumeThread(params: {
|
||||
}),
|
||||
);
|
||||
const response = assertCodexThreadResumeResponse(
|
||||
await lifecycleTiming.measure("thread_resume_request", () =>
|
||||
await lifecycleTiming.measure("thread-resume-request", () =>
|
||||
params.client.request("thread/resume", resumeParams, { signal: params.signal }),
|
||||
),
|
||||
);
|
||||
@@ -479,7 +562,7 @@ export async function startOrResumeThread(params: {
|
||||
params.mcpServersFingerprintEvaluated === true
|
||||
? params.mcpServersFingerprint
|
||||
: binding.mcpServersFingerprint;
|
||||
await lifecycleTiming.measure("thread_resume_write_binding", () =>
|
||||
await lifecycleTiming.measure("thread-resume-write-binding", () =>
|
||||
writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
@@ -489,6 +572,7 @@ export async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration:
|
||||
@@ -518,7 +602,8 @@ export async function startOrResumeThread(params: {
|
||||
action: "resumed",
|
||||
});
|
||||
}
|
||||
lifecycleTiming.logIfSlow({
|
||||
lifecycleTiming.mark("thread-ready");
|
||||
lifecycleTiming.logSummary({
|
||||
runId: params.params.runId,
|
||||
sessionId: params.params.sessionId,
|
||||
sessionKey: params.params.sessionKey,
|
||||
@@ -533,6 +618,7 @@ export async function startOrResumeThread(params: {
|
||||
model: params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? fallbackModelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration:
|
||||
@@ -558,7 +644,7 @@ export async function startOrResumeThread(params: {
|
||||
|
||||
const pluginThreadConfig = params.pluginThreadConfig?.enabled
|
||||
? (prebuiltPluginThreadConfig ??
|
||||
(await lifecycleTiming.measure("plugin_config_build", () =>
|
||||
(await lifecycleTiming.measure("plugin-config-build", () =>
|
||||
params.pluginThreadConfig?.build(),
|
||||
)))
|
||||
: undefined;
|
||||
@@ -566,7 +652,7 @@ export async function startOrResumeThread(params: {
|
||||
configPatch: params.finalConfigPatch,
|
||||
nativeHookRelayGeneration: params.nativeHookRelayGeneration,
|
||||
};
|
||||
const config = lifecycleTiming.measureSync("merge_thread_config", () =>
|
||||
const config = lifecycleTiming.measureSync("merge-thread-config", () =>
|
||||
mergeCodexThreadConfigs(
|
||||
params.config,
|
||||
userMcpServersConfigPatch,
|
||||
@@ -574,7 +660,7 @@ export async function startOrResumeThread(params: {
|
||||
finalConfigPatch.configPatch,
|
||||
),
|
||||
);
|
||||
const startParams = lifecycleTiming.measureSync("thread_start_params", () =>
|
||||
const startParams = lifecycleTiming.measureSync("thread-start-params", () =>
|
||||
buildThreadStartParams(params.params, {
|
||||
cwd: params.cwd,
|
||||
dynamicTools: params.dynamicTools,
|
||||
@@ -586,7 +672,7 @@ export async function startOrResumeThread(params: {
|
||||
environmentSelection: params.environmentSelection,
|
||||
}),
|
||||
);
|
||||
const threadStartResponse = await lifecycleTiming.measure("thread_start_request", async () => {
|
||||
const threadStartResponse = await lifecycleTiming.measure("thread-start-request", async () => {
|
||||
try {
|
||||
return await params.client.request("thread/start", startParams, { signal: params.signal });
|
||||
} catch (error) {
|
||||
@@ -609,7 +695,7 @@ export async function startOrResumeThread(params: {
|
||||
const nextMcpServersFingerprint =
|
||||
params.mcpServersFingerprintEvaluated === true ? params.mcpServersFingerprint : undefined;
|
||||
if (!preserveExistingBinding) {
|
||||
await lifecycleTiming.measure("thread_start_write_binding", () =>
|
||||
await lifecycleTiming.measure("thread-start-write-binding", () =>
|
||||
writeCodexAppServerBinding(
|
||||
params.params.sessionFile,
|
||||
{
|
||||
@@ -619,6 +705,7 @@ export async function startOrResumeThread(params: {
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
@@ -648,7 +735,8 @@ export async function startOrResumeThread(params: {
|
||||
});
|
||||
}
|
||||
}
|
||||
lifecycleTiming.logIfSlow({
|
||||
lifecycleTiming.mark("thread-ready");
|
||||
lifecycleTiming.logSummary({
|
||||
runId: params.params.runId,
|
||||
sessionId: params.params.sessionId,
|
||||
sessionKey: params.params.sessionKey,
|
||||
@@ -664,6 +752,7 @@ export async function startOrResumeThread(params: {
|
||||
model: response.model ?? params.params.modelId,
|
||||
modelProvider: response.modelProvider ?? modelProvider,
|
||||
dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
@@ -924,7 +1013,14 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
config: JsonObject | undefined,
|
||||
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
|
||||
): JsonObject {
|
||||
const runtimeConfig = buildCodexRuntimeThreadConfig(config, options);
|
||||
const baseConfig = buildCodexRuntimeThreadConfig(config, options);
|
||||
const runtimeConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
baseConfig,
|
||||
shouldDisableCodexToolSearchForModel(params.modelId)
|
||||
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
|
||||
: undefined,
|
||||
) ?? baseConfig;
|
||||
if (params.bootstrapContextMode !== "lightweight") {
|
||||
return runtimeConfig;
|
||||
}
|
||||
@@ -1114,9 +1210,7 @@ function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue {
|
||||
for (const [key, child] of Object.entries(tool).toSorted(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
// Tool-search presentation can change per turn without changing the
|
||||
// durable app-server execution contract for an existing thread.
|
||||
if (key === "description" || key === "deferLoading" || key === "namespace") {
|
||||
if (key === "description") {
|
||||
continue;
|
||||
}
|
||||
stable[key] = stabilizeJsonValue(child);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const telemetryState = vi.hoisted(() => {
|
||||
type TestSpanContext = {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceFlags: number;
|
||||
};
|
||||
const counters = new Map<string, { add: ReturnType<typeof vi.fn> }>();
|
||||
const histograms = new Map<string, { record: ReturnType<typeof vi.fn> }>();
|
||||
const spans: Array<{
|
||||
@@ -9,7 +14,7 @@ const telemetryState = vi.hoisted(() => {
|
||||
end: ReturnType<typeof vi.fn>;
|
||||
setAttributes: ReturnType<typeof vi.fn>;
|
||||
setStatus: ReturnType<typeof vi.fn>;
|
||||
spanContext: ReturnType<typeof vi.fn>;
|
||||
spanContext: ReturnType<typeof vi.fn<() => TestSpanContext>>;
|
||||
}> = [];
|
||||
const tracer = {
|
||||
startSpan: vi.fn((name: string, _opts?: unknown, _ctx?: unknown) => {
|
||||
@@ -20,7 +25,7 @@ const telemetryState = vi.hoisted(() => {
|
||||
end: vi.fn(),
|
||||
setAttributes: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
spanContext: vi.fn(() => ({
|
||||
spanContext: vi.fn<() => TestSpanContext>(() => ({
|
||||
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
||||
spanId,
|
||||
traceFlags: 1,
|
||||
@@ -156,13 +161,21 @@ vi.mock("@opentelemetry/semantic-conventions", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
createDiagnosticTraceContext,
|
||||
emitTrustedDiagnosticEvent,
|
||||
emitTrustedDiagnosticEventWithPrivateData,
|
||||
onInternalDiagnosticEvent,
|
||||
resetDiagnosticEventsForTest,
|
||||
waitForDiagnosticEventsDrained,
|
||||
type DiagnosticEventPrivateData,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { onTrustedInternalDiagnosticEvent } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import {
|
||||
emitInternalDiagnosticEventForTest,
|
||||
logMessageDispatchStarted,
|
||||
logMessageProcessed,
|
||||
onTrustedInternalDiagnosticEvent,
|
||||
runWithDiagnosticTraceContext,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { OpenClawPluginServiceContext } from "../api.js";
|
||||
import { emitDiagnosticEvent } from "../api.js";
|
||||
import { createDiagnosticsOtelService } from "./service.js";
|
||||
@@ -175,6 +188,12 @@ const SPAN_ID = "00f067aa0ba902b7";
|
||||
const CHILD_SPAN_ID = "1111111111111111";
|
||||
const GRANDCHILD_SPAN_ID = "2222222222222222";
|
||||
const TOOL_SPAN_ID = "3333333333333333";
|
||||
const MODEL_CALL_SPAN_ID = "4444444444444444";
|
||||
const MODEL_USAGE_SPAN_ID = "5555555555555555";
|
||||
|
||||
function numberedSpanId(index: number) {
|
||||
return (index + 0x1000).toString(16).padStart(16, "0");
|
||||
}
|
||||
const PROTO_KEY = "__proto__";
|
||||
const MAX_TEST_OTEL_CONTENT_ATTRIBUTE_CHARS = 128 * 1024;
|
||||
const OTEL_TRUNCATED_SUFFIX_MAX_CHARS = 20;
|
||||
@@ -249,6 +268,27 @@ function startedSpanOptions(name: string) {
|
||||
return startedSpanCall(name)?.[1];
|
||||
}
|
||||
|
||||
function startedSpanParentContexts(name: string) {
|
||||
return telemetryState.tracer.startSpan.mock.calls
|
||||
.filter((call) => call[0] === name)
|
||||
.map(
|
||||
(call) =>
|
||||
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
|
||||
?.spanContext,
|
||||
);
|
||||
}
|
||||
|
||||
function startedSpanParentContextsByName(name: string) {
|
||||
return telemetryState.tracer.startSpan.mock.calls
|
||||
.filter((call) => call[0] === name)
|
||||
.map((call) => ({
|
||||
attributes: (call[1] as { attributes?: Record<string, unknown> } | undefined)?.attributes,
|
||||
parentContext: (
|
||||
call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined
|
||||
)?.spanContext,
|
||||
}));
|
||||
}
|
||||
|
||||
function mockCall(mock: { mock: { calls: unknown[][] } }, callIndex = 0): unknown[] {
|
||||
const call = mock.mock.calls.at(callIndex);
|
||||
if (!call) {
|
||||
@@ -2563,7 +2603,581 @@ describe("diagnostics-otel service", () => {
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("keeps trusted run spans alive long enough for post-completion usage parenting", async () => {
|
||||
test("correlates one channel message waterfall across message, harness, usage, and model spans", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "message.dispatch.started",
|
||||
channel: "slack",
|
||||
source: "replyResolver",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "harness.run.started",
|
||||
runId: "run-1",
|
||||
harnessId: "codex",
|
||||
pluginId: "codex",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
channel: "slack",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
channel: "slack",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: TOOL_SPAN_ID,
|
||||
parentSpanId: GRANDCHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.call.started",
|
||||
runId: "run-1",
|
||||
callId: "call-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
api: "openai-codex-responses",
|
||||
transport: "stdio",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: MODEL_CALL_SPAN_ID,
|
||||
parentSpanId: TOOL_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.call.completed",
|
||||
runId: "run-1",
|
||||
callId: "call-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
api: "openai-codex-responses",
|
||||
transport: "stdio",
|
||||
durationMs: 80,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: MODEL_CALL_SPAN_ID,
|
||||
parentSpanId: TOOL_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "harness.run.completed",
|
||||
runId: "run-1",
|
||||
harnessId: "codex",
|
||||
pluginId: "codex",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
channel: "slack",
|
||||
durationMs: 100,
|
||||
outcome: "completed",
|
||||
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
channel: "slack",
|
||||
agentId: "main",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
usage: { input: 3, output: 2, total: 5 },
|
||||
durationMs: 10,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: MODEL_USAGE_SPAN_ID,
|
||||
parentSpanId: GRANDCHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "message.processed",
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 120,
|
||||
outcome: "completed",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const messageSpan = spanByName("openclaw.message.processed");
|
||||
const harnessSpan = spanByName("openclaw.harness.run");
|
||||
const runSpan = spanByName("openclaw.run");
|
||||
const usageSpan = spanByName("openclaw.model.usage");
|
||||
const modelCallSpan = spanByName("openclaw.model.call");
|
||||
const messageSpanContext = messageSpan.spanContext();
|
||||
const harnessSpanContext = harnessSpan.spanContext();
|
||||
const runSpanContext = runSpan.spanContext();
|
||||
const usageSpanContext = usageSpan.spanContext();
|
||||
const modelCallSpanContext = modelCallSpan.spanContext();
|
||||
|
||||
const parentBySpanName = Object.fromEntries(
|
||||
telemetryState.tracer.startSpan.mock.calls.map((call) => [
|
||||
call[0],
|
||||
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
|
||||
?.spanContext,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(messageSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(harnessSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(usageSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(modelCallSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(parentBySpanName["openclaw.message.processed"]?.spanId).toBe(SPAN_ID);
|
||||
expect(parentBySpanName["openclaw.harness.run"]?.spanId).toBe(messageSpanContext.spanId);
|
||||
expect(parentBySpanName["openclaw.run"]?.spanId).toBe(harnessSpanContext.spanId);
|
||||
expect(parentBySpanName["openclaw.model.usage"]?.spanId).toBe(harnessSpanContext.spanId);
|
||||
expect(parentBySpanName["openclaw.model.call"]?.spanId).toBe(runSpanContext.spanId);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("uses production message lifecycle helpers as the message span anchor", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
const messageTrace = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
|
||||
runWithDiagnosticTraceContext(messageTrace, () => {
|
||||
logMessageDispatchStarted({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
source: "replyResolver",
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "harness.run.started",
|
||||
runId: "run-1",
|
||||
harnessId: "codex",
|
||||
pluginId: "codex",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
channel: "slack",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
channel: "slack",
|
||||
agentId: "main",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
usage: { input: 3, output: 2, total: 5 },
|
||||
durationMs: 10,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: MODEL_USAGE_SPAN_ID,
|
||||
parentSpanId: GRANDCHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
logMessageProcessed({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 120,
|
||||
outcome: "completed",
|
||||
});
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const messageSpan = spanByName("openclaw.message.processed");
|
||||
const harnessSpan = spanByName("openclaw.harness.run");
|
||||
const messageSpanContext = messageSpan.spanContext();
|
||||
const harnessSpanContext = harnessSpan.spanContext();
|
||||
const parentBySpanName = Object.fromEntries(
|
||||
telemetryState.tracer.startSpan.mock.calls.map((call) => [
|
||||
call[0],
|
||||
(call[2] as { spanContext?: { traceId?: string; spanId?: string } } | undefined)
|
||||
?.spanContext,
|
||||
]),
|
||||
);
|
||||
|
||||
expect(parentBySpanName["openclaw.message.processed"]?.spanId).toBe(SPAN_ID);
|
||||
expect(parentBySpanName["openclaw.harness.run"]?.spanId).toBe(messageSpanContext.spanId);
|
||||
expect(parentBySpanName["openclaw.model.usage"]?.spanId).toBe(harnessSpanContext.spanId);
|
||||
expect(messageSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(harnessSpanContext.traceId).toBe(TRACE_ID);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("does not force a remote parent for root message lifecycle helpers", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
const messageTrace = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
|
||||
runWithDiagnosticTraceContext(messageTrace, () => {
|
||||
logMessageDispatchStarted({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
source: "replyResolver",
|
||||
});
|
||||
logMessageProcessed({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 120,
|
||||
outcome: "completed",
|
||||
});
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
expect(spanByName("openclaw.message.processed").spanContext().traceId).toBe(TRACE_ID);
|
||||
expect(startedSpanParentContexts("openclaw.message.processed")[0]).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("parents outbound delivery spans under the active message lifecycle span", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
const messageTrace = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
|
||||
runWithDiagnosticTraceContext(messageTrace, () => {
|
||||
logMessageDispatchStarted({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
source: "replyResolver",
|
||||
});
|
||||
emitInternalDiagnosticEventForTest({
|
||||
type: "message.delivery.completed",
|
||||
channel: "slack",
|
||||
deliveryKind: "text",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 15,
|
||||
resultCount: 1,
|
||||
});
|
||||
emitInternalDiagnosticEventForTest({
|
||||
type: "message.delivery.error",
|
||||
channel: "slack",
|
||||
deliveryKind: "media",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 25,
|
||||
errorCategory: "network",
|
||||
});
|
||||
logMessageProcessed({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 120,
|
||||
outcome: "completed",
|
||||
});
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const messageSpanContext = spanByName("openclaw.message.processed").spanContext();
|
||||
const deliveryParentContexts = startedSpanParentContexts("openclaw.message.delivery");
|
||||
|
||||
expect(deliveryParentContexts).toHaveLength(2);
|
||||
expect(deliveryParentContexts[0]?.traceId).toBe(TRACE_ID);
|
||||
expect(deliveryParentContexts[0]?.spanId).toBe(messageSpanContext.spanId);
|
||||
expect(deliveryParentContexts[1]?.traceId).toBe(TRACE_ID);
|
||||
expect(deliveryParentContexts[1]?.spanId).toBe(messageSpanContext.spanId);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("parents multi-batch late delivery spans from the retained message context", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
const messageTrace = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
|
||||
runWithDiagnosticTraceContext(messageTrace, () => {
|
||||
logMessageDispatchStarted({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
source: "replyResolver",
|
||||
});
|
||||
for (let index = 0; index < 125; index += 1) {
|
||||
emitInternalDiagnosticEventForTest({
|
||||
type: "message.delivery.completed",
|
||||
channel: "slack",
|
||||
deliveryKind: "text",
|
||||
sessionKey: `agent:main:slack:channel:c${index}`,
|
||||
durationMs: 15,
|
||||
resultCount: 1,
|
||||
});
|
||||
}
|
||||
logMessageProcessed({
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 120,
|
||||
outcome: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
const messageSpan = spanByName("openclaw.message.processed");
|
||||
const messageSpanContext = messageSpan.spanContext();
|
||||
expect(messageSpan.end).toHaveBeenCalledTimes(1);
|
||||
await waitForDiagnosticEventsDrained();
|
||||
|
||||
const deliveryParentContexts = startedSpanParentContexts("openclaw.message.delivery");
|
||||
expect(deliveryParentContexts).toHaveLength(125);
|
||||
expect(deliveryParentContexts.every((parent) => parent?.traceId === TRACE_ID)).toBe(true);
|
||||
expect(
|
||||
deliveryParentContexts.every((parent) => parent?.spanId === messageSpanContext.spanId),
|
||||
).toBe(true);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("correlates skipped duplicate message lifecycle helpers to the active inbound trace", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
const messageTrace = createDiagnosticTraceContext({
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
});
|
||||
|
||||
runWithDiagnosticTraceContext(messageTrace, () => {
|
||||
logMessageProcessed({
|
||||
channel: "slack",
|
||||
messageId: "msg-duplicate",
|
||||
chatId: "c1",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 5,
|
||||
outcome: "skipped",
|
||||
reason: "duplicate",
|
||||
});
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const messageSpan = spanByName("openclaw.message.processed");
|
||||
const messageSpanContext = messageSpan.spanContext();
|
||||
const parentContext = startedSpanParentContexts("openclaw.message.processed")[0];
|
||||
|
||||
expect(messageSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(parentContext?.traceId).toBe(TRACE_ID);
|
||||
expect(parentContext?.spanId).toBe(SPAN_ID);
|
||||
expect(firstSpanAttributes("openclaw.message.processed")["openclaw.reason"]).toBe("duplicate");
|
||||
expect(messageSpan.end).toHaveBeenCalledTimes(1);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("does not force a remote parent for fallback root message processed spans", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "message.processed",
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 25,
|
||||
outcome: "skipped",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
expect(spanByName("openclaw.message.processed").spanContext().traceId).toBe(TRACE_ID);
|
||||
expect(startedSpanParentContexts("openclaw.message.processed")[0]).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("does not retain fallback message processed spans as active parents", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "message.processed",
|
||||
channel: "slack",
|
||||
sessionKey: "agent:main:slack:channel:c1",
|
||||
durationMs: 25,
|
||||
outcome: "skipped",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
expect(spanByName("openclaw.message.processed").end).toHaveBeenCalledTimes(1);
|
||||
|
||||
telemetryState.tracer.setSpanContext.mockClear();
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "harness.run.started",
|
||||
runId: "run-1",
|
||||
harnessId: "codex",
|
||||
pluginId: "codex",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
channel: "slack",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
|
||||
expect(startedSpanCall("openclaw.harness.run")?.[2]).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("retains trusted run context long enough for exact post-completion usage parenting", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.completed",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
outcome: "completed",
|
||||
durationMs: 100,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
usage: { input: 3, output: 2, total: 5 },
|
||||
durationMs: 10,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const runSpan = telemetryState.spans.find((span) => span.name === "openclaw.run");
|
||||
const runSpanId = runSpan?.spanContext.mock.results[0]?.value?.spanId;
|
||||
const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find(
|
||||
(call) => call[0] === "openclaw.model.usage",
|
||||
);
|
||||
|
||||
const linkedSpanContext = firstSetSpanContext();
|
||||
expect(linkedSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(linkedSpanContext.spanId).toBe(runSpanId);
|
||||
expect(
|
||||
(modelUsageCall?.[2] as { spanContext?: { spanId?: string } } | undefined)?.spanContext
|
||||
?.spanId,
|
||||
).toBe(runSpanId);
|
||||
expect(firstSpanEndTime("openclaw.run")).toBeTypeOf("number");
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("does not parent sibling active runs through shared upstream aliases", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-2",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
const runContexts = startedSpanParentContextsByName("openclaw.run");
|
||||
|
||||
expect(runContexts).toHaveLength(2);
|
||||
expect(runContexts[0]?.parentContext).toBeUndefined();
|
||||
expect(runContexts[1]?.parentContext).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("does not parent sibling runs through retained upstream aliases", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
@@ -2594,6 +3208,201 @@ describe("diagnostics-otel service", () => {
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-2",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
const runContexts = startedSpanParentContextsByName("openclaw.run");
|
||||
|
||||
expect(runContexts).toHaveLength(2);
|
||||
expect(runContexts[0]?.parentContext).toBeUndefined();
|
||||
expect(runContexts[1]?.parentContext).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("parents retained upstream alias events only when the owner matches", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.call.completed",
|
||||
runId: "run-1",
|
||||
callId: "call-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
durationMs: 80,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: MODEL_CALL_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.completed",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
outcome: "completed",
|
||||
durationMs: 100,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const runSpanContext = spanByName("openclaw.run").spanContext();
|
||||
const modelParentContext = startedSpanParentContexts("openclaw.model.call")[0];
|
||||
|
||||
expect(modelParentContext?.traceId).toBe(TRACE_ID);
|
||||
expect(modelParentContext?.spanId).toBe(runSpanContext.spanId);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("parents multi-batch late model spans from the retained run context", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
for (let index = 0; index < 125; index += 1) {
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.call.completed",
|
||||
runId: "run-1",
|
||||
callId: `call-${index}`,
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
durationMs: 80,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: numberedSpanId(index),
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
}
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.completed",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
outcome: "completed",
|
||||
durationMs: 100,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
const runSpan = spanByName("openclaw.run");
|
||||
const runSpanContext = runSpan.spanContext();
|
||||
expect(runSpan.end).toHaveBeenCalledTimes(1);
|
||||
await waitForDiagnosticEventsDrained();
|
||||
|
||||
const modelParentContexts = startedSpanParentContexts("openclaw.model.call");
|
||||
expect(modelParentContexts).toHaveLength(125);
|
||||
expect(modelParentContexts.every((parent) => parent?.traceId === TRACE_ID)).toBe(true);
|
||||
expect(modelParentContexts.every((parent) => parent?.spanId === runSpanContext.spanId)).toBe(
|
||||
true,
|
||||
);
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("removes retained run contexts after queued diagnostics drain", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
for (let index = 0; index < 125; index += 1) {
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.call.completed",
|
||||
runId: "run-1",
|
||||
callId: `call-${index}`,
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
durationMs: 80,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: numberedSpanId(index),
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
}
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.completed",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
outcome: "completed",
|
||||
durationMs: 100,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
await waitForDiagnosticEventsDrained();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
await waitForDiagnosticEventsDrained();
|
||||
await Promise.resolve();
|
||||
telemetryState.tracer.setSpanContext.mockClear();
|
||||
telemetryState.tracer.startSpan.mockClear();
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
provider: "openai",
|
||||
@@ -2603,26 +3412,69 @@ describe("diagnostics-otel service", () => {
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
|
||||
expect(startedSpanCall("openclaw.model.usage")?.[2]).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
test("clears retained run contexts when the service stops", async () => {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true });
|
||||
await service.start(ctx);
|
||||
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.started",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "run.completed",
|
||||
runId: "run-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
outcome: "completed",
|
||||
durationMs: 100,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: CHILD_SPAN_ID,
|
||||
parentSpanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
const runSpan = telemetryState.spans.find((span) => span.name === "openclaw.run");
|
||||
const runSpanId = runSpan?.spanContext.mock.results[0]?.value?.spanId;
|
||||
const modelUsageCall = telemetryState.tracer.startSpan.mock.calls.find(
|
||||
(call) => call[0] === "openclaw.model.usage",
|
||||
);
|
||||
await service.stop?.(ctx);
|
||||
await service.start(ctx);
|
||||
telemetryState.tracer.setSpanContext.mockClear();
|
||||
telemetryState.tracer.startSpan.mockClear();
|
||||
|
||||
const linkedSpanContext = firstSetSpanContext();
|
||||
expect(linkedSpanContext.traceId).toBe(TRACE_ID);
|
||||
expect(linkedSpanContext.spanId).toBe(runSpanId);
|
||||
expect(
|
||||
(modelUsageCall?.[2] as { spanContext?: { spanId?: string } } | undefined)?.spanContext
|
||||
?.spanId,
|
||||
).toBe(runSpanId);
|
||||
expect(firstSpanEndTime("openclaw.run")).toBeTypeOf("number");
|
||||
emitTrustedDiagnosticEvent({
|
||||
type: "model.usage",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
usage: { input: 3, output: 2, total: 5 },
|
||||
durationMs: 10,
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: GRANDCHILD_SPAN_ID,
|
||||
parentSpanId: CHILD_SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
});
|
||||
|
||||
expect(telemetryState.tracer.setSpanContext).not.toHaveBeenCalled();
|
||||
expect(startedSpanCall("openclaw.model.usage")?.[2]).toBeUndefined();
|
||||
await service.stop?.(ctx);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SpanStatusCode,
|
||||
TraceFlags,
|
||||
} from "@opentelemetry/api";
|
||||
import type { SpanContext } from "@opentelemetry/api";
|
||||
import type { LogRecord, SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
ATTR_GEN_AI_SYSTEM_INSTRUCTIONS,
|
||||
ATTR_GEN_AI_TOOL_DEFINITIONS,
|
||||
} from "@opentelemetry/semantic-conventions/incubating";
|
||||
import { waitForDiagnosticEventsDrained } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type {
|
||||
DiagnosticEventMetadata,
|
||||
@@ -86,6 +88,8 @@ const GEN_AI_TOKEN_USAGE_BUCKETS = [
|
||||
const GEN_AI_OPERATION_DURATION_BUCKETS = [
|
||||
0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
|
||||
];
|
||||
const MAX_RETAINED_TRUSTED_SPAN_CONTEXTS = 1024;
|
||||
const RETAINED_TRUSTED_SPAN_CONTEXT_TIMEOUT_MS = 5_000;
|
||||
|
||||
type OtelContentCapturePolicy = {
|
||||
inputMessages: boolean;
|
||||
@@ -128,6 +132,7 @@ type SessionRecoveryDiagnosticEvent = Extract<
|
||||
{ type: "session.recovery.requested" | "session.recovery.completed" }
|
||||
>;
|
||||
type TalkDiagnosticEvent = Extract<DiagnosticEventPayload, { type: "talk.event" }>;
|
||||
type TrustedSpanAliasOwner = { kind: "run"; id: string };
|
||||
|
||||
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
|
||||
inputMessages: false,
|
||||
@@ -1240,17 +1245,25 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
const meter = metrics.getMeter("openclaw");
|
||||
const tracer = trace.getTracer("openclaw");
|
||||
const activeTrustedSpans = new Map<string, ReturnType<typeof tracer.startSpan>>();
|
||||
const activeTrustedSpanAliases = new Map<string, ReturnType<typeof tracer.startSpan>>();
|
||||
const pendingTrustedRunFinalizers = new Map<string, ReturnType<typeof setImmediate>>();
|
||||
const activeTrustedSpanAliases = new Map<
|
||||
string,
|
||||
{ span: ReturnType<typeof tracer.startSpan>; spanId: string; owner: TrustedSpanAliasOwner }
|
||||
>();
|
||||
const retainedTrustedSpanContexts = new Map<
|
||||
string,
|
||||
{ spanContext: SpanContext; token: symbol; owner?: TrustedSpanAliasOwner }
|
||||
>();
|
||||
const retainedTrustedSpanContextCleanupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
stopActiveTrustedSpans = () => {
|
||||
const stopAt = Date.now();
|
||||
for (const handle of pendingTrustedRunFinalizers.values()) {
|
||||
clearImmediate(handle);
|
||||
for (const handle of retainedTrustedSpanContextCleanupTimers) {
|
||||
clearTimeout(handle);
|
||||
}
|
||||
pendingTrustedRunFinalizers.clear();
|
||||
retainedTrustedSpanContextCleanupTimers.clear();
|
||||
retainedTrustedSpanContexts.clear();
|
||||
for (const span of new Set([
|
||||
...activeTrustedSpans.values(),
|
||||
...activeTrustedSpanAliases.values(),
|
||||
...Array.from(activeTrustedSpanAliases.values(), (entry) => entry.span),
|
||||
])) {
|
||||
span.end(stopAt);
|
||||
}
|
||||
@@ -1679,20 +1692,139 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => (metadata.trusted ? normalizeTraceContext(evt.trace) : undefined);
|
||||
const internalOrTrustedTraceContext = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => (metadata.trusted || metadata.internal ? normalizeTraceContext(evt.trace) : undefined);
|
||||
const trustedSpanAliasOwner = (
|
||||
evt: DiagnosticEventPayload,
|
||||
): TrustedSpanAliasOwner | undefined => {
|
||||
if ("runId" in evt && evt.runId) {
|
||||
return { kind: "run", id: evt.runId };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const sameTrustedSpanAliasOwner = (
|
||||
left: TrustedSpanAliasOwner | undefined,
|
||||
right: TrustedSpanAliasOwner | undefined,
|
||||
) => Boolean(left && right && left.kind === right.kind && left.id === right.id);
|
||||
const trustedSpanAliasKey = (spanId: string, owner: TrustedSpanAliasOwner) =>
|
||||
`${spanId}:${owner.kind}:${owner.id}`;
|
||||
const retainedTrustedSpanContextKey = (
|
||||
traceId: string,
|
||||
spanId: string,
|
||||
owner?: TrustedSpanAliasOwner,
|
||||
) => `${traceId}:${owner ? trustedSpanAliasKey(spanId, owner) : spanId}`;
|
||||
const retainedTrustedSpanContext = (
|
||||
traceContext: DiagnosticTraceContext | undefined,
|
||||
spanId: string | undefined,
|
||||
owner?: TrustedSpanAliasOwner,
|
||||
) => {
|
||||
if (!traceContext?.traceId || !spanId) {
|
||||
return undefined;
|
||||
}
|
||||
const retained =
|
||||
(owner
|
||||
? retainedTrustedSpanContexts.get(
|
||||
retainedTrustedSpanContextKey(traceContext.traceId, spanId, owner),
|
||||
)
|
||||
: undefined) ??
|
||||
retainedTrustedSpanContexts.get(
|
||||
retainedTrustedSpanContextKey(traceContext.traceId, spanId),
|
||||
);
|
||||
if (retained?.spanContext.traceId !== traceContext.traceId) {
|
||||
return undefined;
|
||||
}
|
||||
if (retained.owner && !sameTrustedSpanAliasOwner(retained.owner, owner)) {
|
||||
return undefined;
|
||||
}
|
||||
return retained.spanContext;
|
||||
};
|
||||
const activeTrustedSpanAlias = (spanId: string, owner: TrustedSpanAliasOwner | undefined) => {
|
||||
if (!owner) {
|
||||
return undefined;
|
||||
}
|
||||
const alias = activeTrustedSpanAliases.get(trustedSpanAliasKey(spanId, owner));
|
||||
if (!alias || !sameTrustedSpanAliasOwner(alias.owner, owner)) {
|
||||
return undefined;
|
||||
}
|
||||
return alias.span;
|
||||
};
|
||||
const internalOrTrustedParentContext = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const traceContext = internalOrTrustedTraceContext(evt, metadata);
|
||||
const parentSpanId = traceContext?.parentSpanId ?? traceContext?.spanId;
|
||||
if (!traceContext || !parentSpanId) {
|
||||
return undefined;
|
||||
}
|
||||
return contextForTraceContext({
|
||||
...traceContext,
|
||||
spanId: parentSpanId,
|
||||
});
|
||||
};
|
||||
const internalOrTrustedExplicitParentContext = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const traceContext = internalOrTrustedTraceContext(evt, metadata);
|
||||
if (!traceContext?.parentSpanId) {
|
||||
return undefined;
|
||||
}
|
||||
return contextForTraceContext({
|
||||
...traceContext,
|
||||
spanId: traceContext.parentSpanId,
|
||||
});
|
||||
};
|
||||
const activeTrustedParentContext = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const parentSpanId = trustedTraceContext(evt, metadata)?.parentSpanId;
|
||||
const traceContext = trustedTraceContext(evt, metadata);
|
||||
const parentSpanId = traceContext?.parentSpanId;
|
||||
if (!parentSpanId) {
|
||||
return undefined;
|
||||
}
|
||||
const owner = trustedSpanAliasOwner(evt);
|
||||
const activeParentSpan =
|
||||
activeTrustedSpans.get(parentSpanId) ?? activeTrustedSpanAliases.get(parentSpanId);
|
||||
if (!activeParentSpan) {
|
||||
activeTrustedSpans.get(parentSpanId) ?? activeTrustedSpanAlias(parentSpanId, owner);
|
||||
const spanContext =
|
||||
activeParentSpan?.spanContext() ??
|
||||
retainedTrustedSpanContext(traceContext, parentSpanId, owner);
|
||||
if (!spanContext) {
|
||||
return undefined;
|
||||
}
|
||||
return trace.setSpanContext(otelContextApi.active(), activeParentSpan.spanContext());
|
||||
return trace.setSpanContext(otelContextApi.active(), spanContext);
|
||||
};
|
||||
const activeInternalOrTrustedContext = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const traceContext = internalOrTrustedTraceContext(evt, metadata);
|
||||
if (!traceContext) {
|
||||
return undefined;
|
||||
}
|
||||
const owner = trustedSpanAliasOwner(evt);
|
||||
const activeSpan =
|
||||
(traceContext.spanId
|
||||
? (activeTrustedSpans.get(traceContext.spanId) ??
|
||||
activeTrustedSpanAlias(traceContext.spanId, owner))
|
||||
: undefined) ??
|
||||
(traceContext.parentSpanId
|
||||
? (activeTrustedSpans.get(traceContext.parentSpanId) ??
|
||||
activeTrustedSpanAlias(traceContext.parentSpanId, owner))
|
||||
: undefined);
|
||||
if (activeSpan) {
|
||||
return trace.setSpanContext(otelContextApi.active(), activeSpan.spanContext());
|
||||
}
|
||||
const retainedSpanContext =
|
||||
retainedTrustedSpanContext(traceContext, traceContext.spanId, owner) ??
|
||||
retainedTrustedSpanContext(traceContext, traceContext.parentSpanId, owner);
|
||||
if (retainedSpanContext) {
|
||||
return trace.setSpanContext(otelContextApi.active(), retainedSpanContext);
|
||||
}
|
||||
return internalOrTrustedParentContext(evt, metadata);
|
||||
};
|
||||
const trackTrustedSpan = (
|
||||
evt: DiagnosticEventPayload,
|
||||
@@ -1705,6 +1837,17 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
}
|
||||
return span;
|
||||
};
|
||||
const trackInternalOrTrustedSpan = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
span: ReturnType<typeof tracer.startSpan>,
|
||||
) => {
|
||||
const spanId = internalOrTrustedTraceContext(evt, metadata)?.spanId;
|
||||
if (spanId) {
|
||||
activeTrustedSpans.set(spanId, span);
|
||||
}
|
||||
return span;
|
||||
};
|
||||
const takeTrackedTrustedSpan = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
@@ -1719,33 +1862,109 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
}
|
||||
return span;
|
||||
};
|
||||
const getTrackedInternalOrTrustedSpan = (
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const spanId = internalOrTrustedTraceContext(evt, metadata)?.spanId;
|
||||
if (!spanId) {
|
||||
return undefined;
|
||||
}
|
||||
return activeTrustedSpans.get(spanId);
|
||||
};
|
||||
const setSpanAttrs = (
|
||||
span: ReturnType<typeof tracer.startSpan>,
|
||||
attributes: Record<string, string | number | boolean>,
|
||||
) => {
|
||||
span.setAttributes?.(redactOtelAttributes(attributes));
|
||||
};
|
||||
const scheduleTrackedRunSpanFinalize = (
|
||||
const retainTrustedSpanContext = (
|
||||
traceId: string,
|
||||
spanId: string,
|
||||
spanContext: SpanContext,
|
||||
token: symbol,
|
||||
owner?: TrustedSpanAliasOwner,
|
||||
) => {
|
||||
retainedTrustedSpanContexts.set(retainedTrustedSpanContextKey(traceId, spanId, owner), {
|
||||
spanContext,
|
||||
token,
|
||||
...(owner ? { owner } : {}),
|
||||
});
|
||||
while (retainedTrustedSpanContexts.size > MAX_RETAINED_TRUSTED_SPAN_CONTEXTS) {
|
||||
const oldestKey = retainedTrustedSpanContexts.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
retainedTrustedSpanContexts.delete(oldestKey);
|
||||
}
|
||||
};
|
||||
const scheduleRetainedTrustedSpanContextCleanup = (token: symbol) => {
|
||||
let drainHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = () => {
|
||||
if (drainHandle) {
|
||||
clearTimeout(drainHandle);
|
||||
retainedTrustedSpanContextCleanupTimers.delete(drainHandle);
|
||||
drainHandle = undefined;
|
||||
}
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
retainedTrustedSpanContextCleanupTimers.delete(timeoutHandle);
|
||||
timeoutHandle = undefined;
|
||||
}
|
||||
for (const [key, retained] of retainedTrustedSpanContexts) {
|
||||
if (retained.token === token) {
|
||||
retainedTrustedSpanContexts.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
drainHandle = setTimeout(() => {
|
||||
if (drainHandle) {
|
||||
retainedTrustedSpanContextCleanupTimers.delete(drainHandle);
|
||||
drainHandle = undefined;
|
||||
}
|
||||
void waitForDiagnosticEventsDrained().then(cleanup, cleanup);
|
||||
}, 0);
|
||||
(drainHandle as { unref?: () => void }).unref?.();
|
||||
retainedTrustedSpanContextCleanupTimers.add(drainHandle);
|
||||
timeoutHandle = setTimeout(cleanup, RETAINED_TRUSTED_SPAN_CONTEXT_TIMEOUT_MS);
|
||||
(timeoutHandle as { unref?: () => void }).unref?.();
|
||||
retainedTrustedSpanContextCleanupTimers.add(timeoutHandle);
|
||||
};
|
||||
const completeTrackedLifecycleSpan = (
|
||||
spanId: string,
|
||||
parentSpanId: string | undefined,
|
||||
span: ReturnType<typeof tracer.startSpan>,
|
||||
endTimeMs: number,
|
||||
) => {
|
||||
const existingHandle = pendingTrustedRunFinalizers.get(spanId);
|
||||
if (existingHandle) {
|
||||
clearImmediate(existingHandle);
|
||||
const spanContext = span.spanContext();
|
||||
const retainedKeys: Array<{ spanId: string; owner?: TrustedSpanAliasOwner }> = [{ spanId }];
|
||||
const retainedAliasKeys: string[] = [];
|
||||
for (const [aliasKey, alias] of activeTrustedSpanAliases) {
|
||||
if (alias.span === span) {
|
||||
retainedKeys.push({ spanId: alias.spanId, owner: alias.owner });
|
||||
retainedAliasKeys.push(aliasKey);
|
||||
}
|
||||
}
|
||||
const handle = setImmediate(() => {
|
||||
pendingTrustedRunFinalizers.delete(spanId);
|
||||
if (activeTrustedSpans.get(spanId) === span) {
|
||||
activeTrustedSpans.delete(spanId);
|
||||
if (activeTrustedSpans.get(spanId) === span) {
|
||||
activeTrustedSpans.delete(spanId);
|
||||
}
|
||||
for (const aliasKey of retainedAliasKeys) {
|
||||
if (activeTrustedSpanAliases.get(aliasKey)?.span === span) {
|
||||
activeTrustedSpanAliases.delete(aliasKey);
|
||||
}
|
||||
if (parentSpanId && activeTrustedSpanAliases.get(parentSpanId) === span) {
|
||||
activeTrustedSpanAliases.delete(parentSpanId);
|
||||
}
|
||||
span.end(endTimeMs);
|
||||
});
|
||||
pendingTrustedRunFinalizers.set(spanId, handle);
|
||||
}
|
||||
span.end(endTimeMs);
|
||||
const token = Symbol("retainedTrustedSpanContext");
|
||||
for (const retainedKey of retainedKeys) {
|
||||
retainTrustedSpanContext(
|
||||
spanContext.traceId,
|
||||
retainedKey.spanId,
|
||||
spanContext,
|
||||
token,
|
||||
retainedKey.owner,
|
||||
);
|
||||
}
|
||||
scheduleRetainedTrustedSpanContextCleanup(token);
|
||||
};
|
||||
|
||||
const addRunAttrs = (
|
||||
@@ -1962,11 +2181,28 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
|
||||
const recordMessageDispatchStarted = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "message.dispatch.started" }>,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
messageDispatchStartedCounter.add(1, {
|
||||
const attrs = {
|
||||
"openclaw.channel": lowCardinalityAttr(evt.channel),
|
||||
"openclaw.source": lowCardinalityAttr(evt.source),
|
||||
});
|
||||
};
|
||||
messageDispatchStartedCounter.add(1, attrs);
|
||||
if (!tracesEnabled) {
|
||||
return;
|
||||
}
|
||||
const traceContext = internalOrTrustedTraceContext(evt, metadata);
|
||||
if (!traceContext?.spanId || activeTrustedSpans.has(traceContext.spanId)) {
|
||||
return;
|
||||
}
|
||||
trackInternalOrTrustedSpan(
|
||||
evt,
|
||||
metadata,
|
||||
spanWithDuration("openclaw.message.processed", attrs, undefined, {
|
||||
parentContext: internalOrTrustedExplicitParentContext(evt, metadata),
|
||||
startTimeMs: evt.ts,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const recordMessageDispatchCompleted = (
|
||||
@@ -1984,6 +2220,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
|
||||
const recordMessageProcessed = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const attrs = {
|
||||
"openclaw.channel": lowCardinalityAttr(evt.channel),
|
||||
@@ -2000,11 +2237,23 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
if (evt.reason) {
|
||||
spanAttrs["openclaw.reason"] = lowCardinalityAttr(evt.reason, "unknown");
|
||||
}
|
||||
const span = spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs);
|
||||
const trackedSpan = getTrackedInternalOrTrustedSpan(evt, metadata);
|
||||
const span =
|
||||
trackedSpan ??
|
||||
spanWithDuration("openclaw.message.processed", spanAttrs, evt.durationMs, {
|
||||
parentContext: internalOrTrustedExplicitParentContext(evt, metadata),
|
||||
endTimeMs: evt.ts,
|
||||
});
|
||||
setSpanAttrs(span, spanAttrs);
|
||||
if (evt.outcome === "error" && evt.error) {
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: redactSensitiveText(evt.error) });
|
||||
}
|
||||
span.end();
|
||||
const traceContext = internalOrTrustedTraceContext(evt, metadata);
|
||||
if (trackedSpan && traceContext?.spanId) {
|
||||
completeTrackedLifecycleSpan(traceContext.spanId, trackedSpan, evt.ts);
|
||||
return;
|
||||
}
|
||||
span.end(evt.ts);
|
||||
};
|
||||
|
||||
const messageDeliveryAttrs = (
|
||||
@@ -2022,6 +2271,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
|
||||
const recordMessageDeliveryCompleted = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "message.delivery.completed" }>,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const attrs = {
|
||||
...messageDeliveryAttrs(evt),
|
||||
@@ -2038,13 +2288,14 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
"openclaw.delivery.result_count": evt.resultCount,
|
||||
},
|
||||
evt.durationMs,
|
||||
{ endTimeMs: evt.ts },
|
||||
{ parentContext: activeInternalOrTrustedContext(evt, metadata), endTimeMs: evt.ts },
|
||||
);
|
||||
span.end(evt.ts);
|
||||
};
|
||||
|
||||
const recordMessageDeliveryError = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "message.delivery.error" }>,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) => {
|
||||
const attrs = {
|
||||
...messageDeliveryAttrs(evt),
|
||||
@@ -2056,6 +2307,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
return;
|
||||
}
|
||||
const span = spanWithDuration("openclaw.message.delivery", attrs, evt.durationMs, {
|
||||
parentContext: activeInternalOrTrustedContext(evt, metadata),
|
||||
endTimeMs: evt.ts,
|
||||
});
|
||||
span.setStatus({
|
||||
@@ -2084,7 +2336,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
);
|
||||
const parentSpanId = trustedTraceContext(evt, metadata)?.parentSpanId;
|
||||
if (parentSpanId && !activeTrustedSpans.has(parentSpanId)) {
|
||||
activeTrustedSpanAliases.set(parentSpanId, span);
|
||||
const owner: TrustedSpanAliasOwner = { kind: "run", id: evt.runId };
|
||||
activeTrustedSpanAliases.set(trustedSpanAliasKey(parentSpanId, owner), {
|
||||
span,
|
||||
spanId: parentSpanId,
|
||||
owner,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2363,12 +2620,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
});
|
||||
}
|
||||
if (trackedSpan && trustedTrace?.spanId) {
|
||||
scheduleTrackedRunSpanFinalize(
|
||||
trustedTrace.spanId,
|
||||
trustedTrace.parentSpanId,
|
||||
trackedSpan,
|
||||
evt.ts,
|
||||
);
|
||||
completeTrackedLifecycleSpan(trustedTrace.spanId, trackedSpan, evt.ts);
|
||||
return;
|
||||
}
|
||||
span.end(evt.ts);
|
||||
@@ -2428,8 +2680,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
spanAttrs["openclaw.harness.items.completed"] = evt.itemLifecycle.completedCount;
|
||||
spanAttrs["openclaw.harness.items.active"] = evt.itemLifecycle.activeCount;
|
||||
}
|
||||
const trustedTrace = trustedTraceContext(evt, metadata);
|
||||
const trackedSpan = trustedTrace?.spanId
|
||||
? activeTrustedSpans.get(trustedTrace.spanId)
|
||||
: undefined;
|
||||
const span =
|
||||
takeTrackedTrustedSpan(evt, metadata) ??
|
||||
trackedSpan ??
|
||||
spanWithDuration("openclaw.harness.run", spanAttrs, evt.durationMs, {
|
||||
parentContext: activeTrustedParentContext(evt, metadata),
|
||||
endTimeMs: evt.ts,
|
||||
@@ -2441,6 +2697,10 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
message: "error",
|
||||
});
|
||||
}
|
||||
if (trackedSpan && trustedTrace?.spanId) {
|
||||
completeTrackedLifecycleSpan(trustedTrace.spanId, trackedSpan, evt.ts);
|
||||
return;
|
||||
}
|
||||
span.end(evt.ts);
|
||||
};
|
||||
|
||||
@@ -3076,22 +3336,22 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
recordMessageReceived(evt);
|
||||
return;
|
||||
case "message.dispatch.started":
|
||||
recordMessageDispatchStarted(evt);
|
||||
recordMessageDispatchStarted(evt, metadata);
|
||||
return;
|
||||
case "message.dispatch.completed":
|
||||
recordMessageDispatchCompleted(evt);
|
||||
return;
|
||||
case "message.processed":
|
||||
recordMessageProcessed(evt);
|
||||
recordMessageProcessed(evt, metadata);
|
||||
return;
|
||||
case "message.delivery.started":
|
||||
recordMessageDeliveryStarted(evt);
|
||||
return;
|
||||
case "message.delivery.completed":
|
||||
recordMessageDeliveryCompleted(evt);
|
||||
recordMessageDeliveryCompleted(evt, metadata);
|
||||
return;
|
||||
case "message.delivery.error":
|
||||
recordMessageDeliveryError(evt);
|
||||
recordMessageDeliveryError(evt, metadata);
|
||||
return;
|
||||
case "talk.event":
|
||||
recordTalkEvent(evt, metadata);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelProgressDraftMaxLineChars,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingProgressCommentary,
|
||||
@@ -281,6 +282,13 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const displayLine = formatReasoningProgressDisplayLine(
|
||||
normalized,
|
||||
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
|
||||
);
|
||||
if (!displayLine) {
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
|
||||
const priorIndex =
|
||||
lastReasoningProgressLine === undefined
|
||||
@@ -288,13 +296,13 @@ export function createDiscordDraftPreviewController(params: {
|
||||
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
|
||||
if (priorIndex >= 0) {
|
||||
previewToolProgressLines = [...previewToolProgressLines];
|
||||
previewToolProgressLines[priorIndex] = normalized;
|
||||
previewToolProgressLines[priorIndex] = displayLine;
|
||||
} else {
|
||||
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
|
||||
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
|
||||
-resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
);
|
||||
}
|
||||
lastReasoningProgressLine = normalized;
|
||||
lastReasoningProgressLine = displayLine;
|
||||
}
|
||||
const progressActive = await progressDraftGate.noteWork();
|
||||
if (progressActive && progressDraftGate.hasStarted) {
|
||||
@@ -465,11 +473,57 @@ export function createDiscordDraftPreviewController(params: {
|
||||
|
||||
function normalizeReasoningProgressLine(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i, "")
|
||||
.replace(
|
||||
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressInput(text: string): string {
|
||||
const normalized = normalizeReasoningProgressLine(text);
|
||||
const italic = normalized.match(/^_(.*)_$/u);
|
||||
return (italic?.[1] ?? normalized).trim();
|
||||
}
|
||||
|
||||
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalizedText = normalizeReasoningProgressInput(text);
|
||||
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
|
||||
if (!formatted) {
|
||||
return "";
|
||||
}
|
||||
if (Array.from(formatted).length <= maxChars) {
|
||||
return formatted;
|
||||
}
|
||||
const italic = formatted.match(/^_(.*)_$/u);
|
||||
if (!italic) {
|
||||
return compactReasoningProgressDisplayLine(formatted, maxChars);
|
||||
}
|
||||
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
|
||||
return body ? `_${body}_` : "";
|
||||
}
|
||||
|
||||
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
if (maxChars <= 1) {
|
||||
return "…";
|
||||
}
|
||||
const head = chars
|
||||
.slice(0, maxChars - 1)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
const boundary = head.search(/\s+\S*$/u);
|
||||
if (boundary > Math.floor(maxChars * 0.6)) {
|
||||
return `${head.slice(0, boundary).trimEnd()}…`;
|
||||
}
|
||||
return `${head}…`;
|
||||
}
|
||||
|
||||
function normalizeCommentaryProgressText(text: string): string {
|
||||
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
|
||||
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
|
||||
@@ -512,7 +566,9 @@ function mergeReasoningProgressText(
|
||||
}
|
||||
|
||||
function isReasoningSnapshotText(text: string): boolean {
|
||||
return /^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i.test(text);
|
||||
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
|
||||
|
||||
@@ -3123,6 +3123,320 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(updates.join("\n")).not.toContain("Thinking\n");
|
||||
});
|
||||
|
||||
it("accumulates reasoning deltas in Discord progress drafts", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
for (const text of ["Considering", " plugin", " installation", "!"]) {
|
||||
await params?.replyOptions?.onReasoningStream?.({ text });
|
||||
}
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Considering plugin installation!_",
|
||||
);
|
||||
const updates = draftStream.update.mock.calls.map((call) => call[0]);
|
||||
expect(updates.join("\n")).not.toContain("• _!_");
|
||||
});
|
||||
|
||||
it("preserves raw reasoning content that starts with Thinking", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: " through the install plan" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Thinking through the install plan_",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves raw reasoning content that starts with Thinking colon", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking: compare install paths" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Thinking: compare install paths_",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves raw reasoning content that starts with Reasoning colon", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: compare install paths" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Reasoning: compare install paths_",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips legacy Reasoning newline wrappers from progress snapshots", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({
|
||||
text: "Reasoning:\ncompare install paths",
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _compare install paths_",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips legacy Thinking ellipsis display wrappers from progress snapshots", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({
|
||||
text: "Thinking...\n\n_compare install paths_",
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _compare install paths_",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves raw reasoning content that starts with a Thinking line", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking\nthrough the plan" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Thinking through the plan_",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends raw reasoning chunks that start with Thinking", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking about the plan" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _I was Thinking about the plan_",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends raw reasoning chunks that start with Thinking ellipsis", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking... through the plan" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _I was Thinking... through the plan_",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends raw reasoning chunks that start with Reasoning colon", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: through edge cases" });
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _I was Reasoning: through edge cases_",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps reasoning italics balanced when progress lines truncate", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({
|
||||
text: "Thinking through a very detailed installation plan with many steps",
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
discordConfig: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: "Clawing...",
|
||||
maxLineChars: 36,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
|
||||
const reasoningLine = lastUpdate?.split("\n").at(-1);
|
||||
|
||||
expect(reasoningLine).toMatch(/^• _.*…_$/u);
|
||||
expect(reasoningLine?.match(/_/gu)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("replaces reasoning snapshots instead of appending duplicates", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
|
||||
@@ -3152,9 +3466,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Reading _ _Checking_",
|
||||
);
|
||||
expect(draftStream.update.mock.calls.at(-1)?.[0]).toContain("_Reading Checking_");
|
||||
const updates = draftStream.update.mock.calls.map((call) => call[0]);
|
||||
expect(updates.join("\n")).not.toContain("_Checking Reading");
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { MessageFlags } from "discord-api-types/v10";
|
||||
import {
|
||||
formatReasoningMessage,
|
||||
resolveAckReaction,
|
||||
resolveHumanDelayConfig,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
createStatusReactionController,
|
||||
DEFAULT_TIMING,
|
||||
@@ -987,8 +983,7 @@ async function processDiscordMessageInner(
|
||||
: undefined,
|
||||
onReasoningStream: async (payload) => {
|
||||
await statusReactions.setThinking();
|
||||
const formattedText = payload?.text ? formatReasoningMessage(payload.text) : undefined;
|
||||
await draftPreview.pushReasoningProgress(formattedText, {
|
||||
await draftPreview.pushReasoningProgress(payload?.text, {
|
||||
snapshot: payload?.isReasoningSnapshot === true,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1575,7 +1575,7 @@ export async function handleFeishuMessage(params: {
|
||||
turnResult.dispatched &&
|
||||
shouldSendNoVisibleReplyFallback({
|
||||
...turnResult.dispatchResult,
|
||||
failedCounts: dispatcher.getFailedCounts(),
|
||||
failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 },
|
||||
})
|
||||
) {
|
||||
await ensureNoVisibleReplyFallback("broadcast-dispatch-complete-no-visible-reply");
|
||||
@@ -1771,7 +1771,7 @@ export async function handleFeishuMessage(params: {
|
||||
if (
|
||||
shouldSendNoVisibleReplyFallback({
|
||||
...dispatchResult,
|
||||
failedCounts: dispatcher.getFailedCounts(),
|
||||
failedCounts: dispatcher.getFailedCounts?.() ?? { tool: 0, block: 0, final: 0 },
|
||||
})
|
||||
) {
|
||||
await ensureNoVisibleReplyFallback("dispatch-complete-no-visible-reply");
|
||||
|
||||
62
extensions/github-copilot/provider-policy-api.test.ts
Normal file
62
extensions/github-copilot/provider-policy-api.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
|
||||
describe("github-copilot provider-policy-api", () => {
|
||||
it("returns the base level set for non-xhigh GitHub Copilot models", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "github-copilot",
|
||||
modelId: "claude-opus-4.6",
|
||||
})?.levels.map((level) => level.id),
|
||||
).toEqual(["off", "minimal", "low", "medium", "high"]);
|
||||
});
|
||||
|
||||
it("appends xhigh for current static GPT Copilot xhigh ids", () => {
|
||||
for (const modelId of ["gpt-5.4", "gpt-5.3-codex"]) {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "github-copilot",
|
||||
modelId,
|
||||
})?.levels.map((level) => level.id),
|
||||
`model=${modelId}`,
|
||||
).toContain("xhigh");
|
||||
}
|
||||
});
|
||||
|
||||
it("appends xhigh when catalog compat advertises it", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "github-copilot",
|
||||
modelId: "future-copilot-model",
|
||||
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
||||
})?.levels.map((level) => level.id),
|
||||
).toContain("xhigh");
|
||||
});
|
||||
|
||||
it("appends xhigh for static Copilot metadata overrides", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "github-copilot",
|
||||
modelId: "claude-opus-4.7-1m-internal",
|
||||
})?.levels.map((level) => level.id),
|
||||
).toContain("xhigh");
|
||||
});
|
||||
|
||||
it("normalizes the model id casing before xhigh membership checks", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "github-copilot",
|
||||
modelId: "GPT-5.4",
|
||||
})?.levels.map((level) => level.id),
|
||||
).toContain("xhigh");
|
||||
});
|
||||
|
||||
it("returns null for non-GitHub Copilot providers", () => {
|
||||
expect(
|
||||
resolveThinkingProfile({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
39
extensions/github-copilot/provider-policy-api.ts
Normal file
39
extensions/github-copilot/provider-policy-api.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/core";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveStaticCopilotModelOverride } from "./model-metadata.js";
|
||||
|
||||
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.3-codex"] as const;
|
||||
|
||||
function compatSupportsXHigh(
|
||||
compat: { supportedReasoningEfforts?: readonly string[] | null } | null | undefined,
|
||||
) {
|
||||
return (
|
||||
Array.isArray(compat?.supportedReasoningEfforts) &&
|
||||
compat.supportedReasoningEfforts.some(
|
||||
(effort) => normalizeOptionalLowercaseString(effort) === "xhigh",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveThinkingProfile(context: ProviderDefaultThinkingPolicyContext) {
|
||||
if (context.provider.trim().toLowerCase() !== "github-copilot") {
|
||||
return null;
|
||||
}
|
||||
const normalizedModelId = normalizeOptionalLowercaseString(context.modelId) ?? "";
|
||||
const staticCompat = resolveStaticCopilotModelOverride(normalizedModelId)?.compat;
|
||||
const modelSupportsXHigh =
|
||||
COPILOT_XHIGH_MODEL_IDS.includes(normalizedModelId as never) ||
|
||||
compatSupportsXHigh(context.compat) ||
|
||||
compatSupportsXHigh(staticCompat);
|
||||
|
||||
return {
|
||||
levels: [
|
||||
{ id: "off" as const },
|
||||
{ id: "minimal" as const },
|
||||
{ id: "low" as const },
|
||||
{ id: "medium" as const },
|
||||
{ id: "high" as const },
|
||||
...(modelSupportsXHigh ? [{ id: "xhigh" as const }] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGoogleGenerativeAiApi,
|
||||
isGoogleVertexBaseUrl,
|
||||
isGoogleVertexHostname,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
normalizeGoogleProviderConfig,
|
||||
@@ -83,6 +85,23 @@ describe("google generative ai helpers", () => {
|
||||
models: [{ api: "openai-completions" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("google-vertex", {
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects native Google Vertex hosts by hostname only", () => {
|
||||
expect(isGoogleVertexHostname("aiplatform.googleapis.com")).toBe(true);
|
||||
expect(isGoogleVertexHostname("us-central1-aiplatform.googleapis.com")).toBe(true);
|
||||
expect(isGoogleVertexHostname("generativelanguage.googleapis.com")).toBe(false);
|
||||
expect(isGoogleVertexHostname("evil-aiplatform.googleapis.com.attacker.com")).toBe(false);
|
||||
expect(
|
||||
isGoogleVertexBaseUrl(
|
||||
"https://generativelanguage.googleapis.com/v1beta/proxy/aiplatform.googleapis.com",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes transport baseUrls only for Google Generative AI", () => {
|
||||
@@ -114,6 +133,28 @@ describe("google generative ai helpers", () => {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
});
|
||||
expect(
|
||||
resolveGoogleGenerativeAiTransport({
|
||||
provider: "google-vertex",
|
||||
api: undefined,
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com",
|
||||
}),
|
||||
).toEqual({
|
||||
api: "google-vertex",
|
||||
baseUrl: "https://us-central1-aiplatform.googleapis.com",
|
||||
});
|
||||
expect(
|
||||
resolveGoogleGenerativeAiTransport({
|
||||
provider: "google-vertex",
|
||||
api: "openai-completions",
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
|
||||
}),
|
||||
).toEqual({
|
||||
api: "openai-completions",
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes google-vertex model ids without rewriting the OpenAI-compatible baseUrl", () => {
|
||||
|
||||
@@ -30,6 +30,8 @@ export {
|
||||
export {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
isGoogleGenerativeAiApi,
|
||||
isGoogleVertexBaseUrl,
|
||||
isGoogleVertexHostname,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
normalizeGoogleProviderConfig,
|
||||
|
||||
@@ -40,4 +40,9 @@ describe("google model id helpers", () => {
|
||||
expect(normalizeGoogleModelId("gemini-3.1-flash-lite")).toBe("gemini-3.1-flash-lite");
|
||||
expect(normalizeGoogleModelId("gemini-3.1-flash-lite-preview")).toBe("gemini-3.1-flash-lite");
|
||||
});
|
||||
|
||||
it("maps the old Gemma 4 26B shorthand to Google's canonical API id", () => {
|
||||
expect(normalizeGoogleModelId("gemma-4-26b")).toBe("gemma-4-26b-a4b-it");
|
||||
expect(normalizeGoogleModelId("google/gemma-4-26b")).toBe("google/gemma-4-26b-a4b-it");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ export function normalizeGoogleModelId(id: string): string {
|
||||
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
|
||||
return "gemini-3-flash-preview";
|
||||
}
|
||||
if (id === "gemma-4-26b") {
|
||||
return "gemma-4-26b-a4b-it";
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ describe("google provider catalog", () => {
|
||||
expect(provider.api).toBe("google-vertex");
|
||||
expect(provider.baseUrl).toBe("https://{location}-aiplatform.googleapis.com");
|
||||
expect(provider.models.map((model) => model.id)).toEqual(
|
||||
expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview"]),
|
||||
expect.arrayContaining(["gemini-2.5-pro", "gemini-3.1-pro-preview", "gemini-3.1-flash-lite"]),
|
||||
);
|
||||
expect(provider.models.find((model) => model.id === "gemini-3.1-flash-lite")).toMatchObject({
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Google AI Studio and Vertex model ids aligned", () => {
|
||||
|
||||
@@ -43,6 +43,15 @@ const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "gemini-3.1-flash-lite",
|
||||
name: "Gemini 3.1 Flash Lite",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: GOOGLE_GEMINI_COST,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash-preview",
|
||||
name: "Gemini 3 Flash Preview",
|
||||
|
||||
@@ -494,6 +494,24 @@ describe("resolveGoogleGeminiForwardCompatModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes Gemma 4 26B shorthand before cloning templates", () => {
|
||||
const model = resolveGoogleGeminiForwardCompatModel({
|
||||
providerId: "google",
|
||||
ctx: createContext({
|
||||
provider: "google",
|
||||
modelId: "gemma-4-26b",
|
||||
models: [createTemplateModel("google", "gemini-3-flash-preview", { reasoning: false })],
|
||||
}),
|
||||
});
|
||||
|
||||
expectModelFields(model, {
|
||||
provider: "google",
|
||||
id: "gemma-4-26b-a4b-it",
|
||||
api: "google-generative-ai",
|
||||
reasoning: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves template reasoning for non-Gemma 4 gemma models", () => {
|
||||
const model = resolveGoogleGeminiForwardCompatModel({
|
||||
providerId: "google",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeGoogleModelId } from "./model-id.js";
|
||||
|
||||
const GOOGLE_GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
|
||||
const GOOGLE_ANTIGRAVITY_PROVIDER_ID = "google-antigravity";
|
||||
@@ -41,6 +42,9 @@ function normalizeGeminiProRequestId(id: string): string {
|
||||
if (id === "gemini-3-pro" || id === "gemini-3-pro-preview" || id === "gemini-3.1-pro") {
|
||||
return "gemini-3.1-pro-preview";
|
||||
}
|
||||
if (id === "gemma-4-26b") {
|
||||
return normalizeGoogleModelId(id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ type GoogleApiCarrier = {
|
||||
};
|
||||
|
||||
type GoogleProviderConfigLike = GoogleApiCarrier & {
|
||||
baseUrl?: string | null;
|
||||
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
|
||||
};
|
||||
|
||||
@@ -37,6 +38,28 @@ function stripUrlUserInfo(url: URL): void {
|
||||
url.password = "";
|
||||
}
|
||||
|
||||
const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com";
|
||||
const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com";
|
||||
|
||||
export function isGoogleVertexHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return (
|
||||
normalized === GOOGLE_VERTEX_HOST || normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX)
|
||||
);
|
||||
}
|
||||
|
||||
export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean {
|
||||
const raw = normalizeOptionalString(baseUrl);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return isGoogleVertexHostname(new URL(raw).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
|
||||
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
|
||||
try {
|
||||
@@ -85,9 +108,12 @@ export function resolveGoogleGenerativeAiTransport<TApi extends string | null |
|
||||
provider?: string;
|
||||
api: TApi;
|
||||
baseUrl?: string;
|
||||
}): { api: TApi | "google-generative-ai"; baseUrl?: string } {
|
||||
}): { api: TApi | "google-generative-ai" | "google-vertex"; baseUrl?: string } {
|
||||
const api =
|
||||
params.api ??
|
||||
(params.provider === "google-vertex" && isGoogleVertexBaseUrl(params.baseUrl)
|
||||
? "google-vertex"
|
||||
: undefined) ??
|
||||
(params.provider === "google" && params.baseUrl ? "google-generative-ai" : params.api);
|
||||
return {
|
||||
api,
|
||||
@@ -107,6 +133,9 @@ export function shouldNormalizeGoogleGenerativeAiProviderConfig(
|
||||
providerKey: string,
|
||||
provider: GoogleProviderConfigLike,
|
||||
): boolean {
|
||||
if (providerKey === "google-vertex" && isGoogleVertexBaseUrl(provider.baseUrl)) {
|
||||
return false;
|
||||
}
|
||||
if (isGoogleGenerativeAiApi(provider.api)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
67
extensions/google/provider-registration.test.ts
Normal file
67
extensions/google/provider-registration.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Model } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleProvider } from "./provider-registration.js";
|
||||
|
||||
const streamFns = vi.hoisted(() => ({
|
||||
createGenerativeAi: vi.fn(() => vi.fn()),
|
||||
createVertex: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
vi.mock("./transport-stream.js", () => ({
|
||||
createGoogleGenerativeAiTransportStreamFn: streamFns.createGenerativeAi,
|
||||
createGoogleVertexTransportStreamFn: streamFns.createVertex,
|
||||
}));
|
||||
|
||||
function model(overrides: Partial<Model> = {}): Model {
|
||||
return {
|
||||
id: "gemini-2.5-flash",
|
||||
name: "Gemini 2.5 Flash",
|
||||
provider: "google-vertex",
|
||||
api: "google-generative-ai",
|
||||
baseUrl: "https://aiplatform.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 65_536,
|
||||
...overrides,
|
||||
} as Model;
|
||||
}
|
||||
|
||||
describe("buildGoogleProvider createStreamFn", () => {
|
||||
beforeEach(() => {
|
||||
streamFns.createGenerativeAi.mockClear();
|
||||
streamFns.createVertex.mockClear();
|
||||
});
|
||||
|
||||
it("routes native Vertex hosts through the Vertex transport", () => {
|
||||
const provider = buildGoogleProvider();
|
||||
|
||||
provider.createStreamFn?.({
|
||||
provider: "google-vertex",
|
||||
modelId: "gemini-2.5-flash",
|
||||
model: model(),
|
||||
} as never);
|
||||
|
||||
expect(streamFns.createVertex).toHaveBeenCalledTimes(1);
|
||||
expect(streamFns.createGenerativeAi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves explicit OpenAI-compatible Vertex endpoint configs", () => {
|
||||
const provider = buildGoogleProvider();
|
||||
|
||||
const result = provider.createStreamFn?.({
|
||||
provider: "google-vertex",
|
||||
modelId: "gemini-2.5-flash",
|
||||
model: model({
|
||||
api: "openai-completions",
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(streamFns.createVertex).not.toHaveBeenCalled();
|
||||
expect(streamFns.createGenerativeAi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js";
|
||||
import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js";
|
||||
import {
|
||||
isGoogleVertexBaseUrl,
|
||||
normalizeGoogleProviderConfig,
|
||||
resolveGoogleGenerativeAiTransport,
|
||||
} from "./provider-policy.js";
|
||||
@@ -67,12 +68,16 @@ export function buildGoogleProvider(): ProviderPlugin {
|
||||
ctx,
|
||||
}),
|
||||
createStreamFn: ({ model }) => {
|
||||
if (
|
||||
model.api === "google-vertex" ||
|
||||
(model.api === "google-generative-ai" &&
|
||||
(model.provider === "google-vertex" || isGoogleVertexBaseUrl(model.baseUrl)))
|
||||
) {
|
||||
return createGoogleVertexTransportStreamFn();
|
||||
}
|
||||
if (model.api === "google-generative-ai") {
|
||||
return createGoogleGenerativeAiTransportStreamFn();
|
||||
}
|
||||
if (model.api === "google-vertex") {
|
||||
return createGoogleVertexTransportStreamFn();
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
...GOOGLE_GEMINI_PROVIDER_HOOKS,
|
||||
|
||||
@@ -71,6 +71,28 @@ type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpos
|
||||
|
||||
type MemorySourceName = "memory" | "sessions";
|
||||
|
||||
function formatMemoryIndexIdentityWarning(
|
||||
status: ReturnType<MemoryManager["status"]>,
|
||||
agentId: string,
|
||||
): {
|
||||
reason: string;
|
||||
fix: string;
|
||||
} | null {
|
||||
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
|
||||
const reason =
|
||||
(indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") &&
|
||||
typeof indexIdentity.reason === "string"
|
||||
? indexIdentity.reason
|
||||
: undefined;
|
||||
if (!reason) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
reason,
|
||||
fix: `Run: openclaw memory status --index --agent ${agentId}`,
|
||||
};
|
||||
}
|
||||
|
||||
type SourceScan = {
|
||||
source: MemorySourceName;
|
||||
totalFiles: number | null;
|
||||
@@ -868,6 +890,12 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
||||
lines.push(`${label("Embeddings error")} ${warn(embeddingProbe.error)}`);
|
||||
}
|
||||
}
|
||||
const identityWarning = formatMemoryIndexIdentityWarning(status, agentId);
|
||||
if (identityWarning) {
|
||||
lines.push(`${label("Index identity")} ${warn(identityWarning.reason)}`);
|
||||
lines.push(`${label("Vector search")} ${warn("paused until memory is rebuilt")}`);
|
||||
lines.push(`${label("Fix")} ${muted(identityWarning.fix)}`);
|
||||
}
|
||||
if (status.sourceCounts?.length) {
|
||||
lines.push(label("By source"));
|
||||
for (const entry of status.sourceCounts) {
|
||||
@@ -1256,6 +1284,15 @@ export async function runMemorySearch(
|
||||
defaultRuntime.writeJson({ results });
|
||||
return;
|
||||
}
|
||||
const identityWarning =
|
||||
typeof manager.status === "function"
|
||||
? formatMemoryIndexIdentityWarning(manager.status(), agentId)
|
||||
: null;
|
||||
if (identityWarning) {
|
||||
defaultRuntime.error(
|
||||
`Memory index warning: ${identityWarning.reason}. Vector memory search is paused until the index is rebuilt. ${identityWarning.fix}`,
|
||||
);
|
||||
}
|
||||
if (results.length === 0) {
|
||||
defaultRuntime.log("No matches.");
|
||||
return;
|
||||
|
||||
@@ -415,6 +415,36 @@ describe("memory cli", () => {
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints index identity mismatch reasons", async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
mockManager({
|
||||
status: () =>
|
||||
makeMemoryStatus({
|
||||
dirty: true,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
requestedProvider: "ollama",
|
||||
custom: {
|
||||
indexIdentity: {
|
||||
status: "mismatched",
|
||||
reason: "index was built for provider openai, expected ollama",
|
||||
},
|
||||
},
|
||||
}),
|
||||
close,
|
||||
});
|
||||
|
||||
const log = spyRuntimeLogs(defaultRuntime);
|
||||
await runMemoryCli(["status"]);
|
||||
|
||||
expectLogged(log, "Provider: ollama (requested: ollama)");
|
||||
expectLogged(log, "Dirty: yes");
|
||||
expectLogged(log, "Index identity: index was built for provider openai, expected ollama");
|
||||
expectLogged(log, "Vector search: paused until memory is rebuilt");
|
||||
expectLogged(log, "Fix: Run: openclaw memory status --index --agent main");
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps plain status from probing vector or embeddings", async () => {
|
||||
const close = vi.fn(async () => {});
|
||||
const probeVectorAvailability = vi.fn(async () => {
|
||||
|
||||
@@ -1682,7 +1682,8 @@ describe("gateway startup reconciliation", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
|
||||
expect(harness.addCalls).toHaveLength(0);
|
||||
expectLogContains(logger.warn, "cron service unavailable");
|
||||
expectLogNotContains(logger.warn, "cron service unavailable");
|
||||
expectLogContains(logger.debug, "cron service not yet available at gateway_start");
|
||||
|
||||
cronAvailable = true;
|
||||
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
|
||||
@@ -1701,6 +1702,58 @@ describe("gateway startup reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps startup cron retry warnings quiet until the retry window is exhausted", async () => {
|
||||
vi.useFakeTimers();
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const onMock = vi.fn();
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => undefined,
|
||||
});
|
||||
|
||||
expectLogContains(logger.debug, "cron service not yet available at gateway_start");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(
|
||||
constants.STARTUP_CRON_RETRY_DELAY_MS * (constants.STARTUP_CRON_RETRY_MAX_ATTEMPTS - 1),
|
||||
);
|
||||
expectLogNotContains(logger.warn, "cron service unavailable");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
|
||||
|
||||
expectLogContains(logger.warn, "cron service unavailable");
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await triggerGatewayStop(onMock);
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries disabled startup cleanup until cron is available", async () => {
|
||||
vi.useFakeTimers();
|
||||
clearInternalHooks();
|
||||
@@ -1829,6 +1882,67 @@ describe("gateway startup reconciliation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not recreate startup cron from stale enabled config after live memory-core config is removed", async () => {
|
||||
vi.useFakeTimers();
|
||||
clearInternalHooks();
|
||||
const logger = createLogger();
|
||||
const harness = createCronHarness();
|
||||
const onMock = vi.fn();
|
||||
const runtimeCurrentConfig = vi.fn(
|
||||
() =>
|
||||
({
|
||||
plugins: {
|
||||
entries: {},
|
||||
},
|
||||
}) as OpenClawConfig,
|
||||
);
|
||||
const api: DreamingPluginApiTestDouble = {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
frequency: "15 4 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
pluginConfig: {},
|
||||
logger,
|
||||
runtime: {
|
||||
config: {
|
||||
current: runtimeCurrentConfig,
|
||||
},
|
||||
},
|
||||
on: onMock,
|
||||
};
|
||||
|
||||
try {
|
||||
registerShortTermPromotionDreamingForTest(api);
|
||||
let cronAvailable = false;
|
||||
await triggerGatewayStart(onMock, {
|
||||
config: api.config,
|
||||
getCron: () => (cronAvailable ? harness.cron : undefined),
|
||||
});
|
||||
|
||||
cronAvailable = true;
|
||||
await vi.advanceTimersByTimeAsync(constants.STARTUP_CRON_RETRY_DELAY_MS);
|
||||
|
||||
expect(runtimeCurrentConfig).toHaveBeenCalled();
|
||||
expect(harness.addCalls).toHaveLength(0);
|
||||
expectLogNotContains(logger.warn, "cron service unavailable");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await triggerGatewayStop(onMock).catch(() => undefined);
|
||||
clearInternalHooks();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears pending startup cron retry on gateway stop", async () => {
|
||||
vi.useFakeTimers();
|
||||
clearInternalHooks();
|
||||
|
||||
@@ -760,18 +760,18 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
].join("|");
|
||||
|
||||
const reconcileManagedDreamingCron = async (params: {
|
||||
reason: "startup" | "runtime";
|
||||
reason: "startup" | "startup_retry" | "runtime";
|
||||
startupConfig?: OpenClawConfig;
|
||||
startupCron?: (() => CronServiceLike | null) | null;
|
||||
}): Promise<ShortTermPromotionDreamingConfig> => {
|
||||
const startupCfg =
|
||||
params.reason === "startup" ? (params.startupConfig ?? api.config) : resolveCurrentConfig();
|
||||
const pluginConfig =
|
||||
params.reason === "runtime"
|
||||
? resolveMemoryCorePluginConfig(startupCfg)
|
||||
: (resolveMemoryCorePluginConfig(startupCfg) ??
|
||||
params.reason === "startup"
|
||||
? (resolveMemoryCorePluginConfig(startupCfg) ??
|
||||
resolveMemoryCorePluginConfig(api.config) ??
|
||||
api.pluginConfig);
|
||||
api.pluginConfig)
|
||||
: resolveMemoryCorePluginConfig(startupCfg);
|
||||
const config = resolveShortTermPromotionDreamingConfig({
|
||||
pluginConfig,
|
||||
cfg: startupCfg,
|
||||
@@ -784,7 +784,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
// This handles the case where the cron service was not yet available during
|
||||
// gateway_start (250ms deferred init race in startGatewaySidecars) but is
|
||||
// available now. Fixes #67362.
|
||||
if (!cron && params.reason === "runtime" && gatewayContext) {
|
||||
if (!cron && params.reason !== "startup" && gatewayContext) {
|
||||
try {
|
||||
cron = resolveCronServiceFromGatewayContext(gatewayContext);
|
||||
if (cron) {
|
||||
@@ -800,7 +800,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
// Avoid a noisy startup-path warning when the gateway has not exposed cron yet.
|
||||
// The runtime reconciliation path (heartbeat-driven) will still warn if the
|
||||
// cron service remains unavailable after boot.
|
||||
if (params.reason === "startup") {
|
||||
if (params.reason === "startup" || params.reason === "startup_retry") {
|
||||
api.logger.debug?.(
|
||||
"memory-core: cron service not yet available at gateway_start; deferring to runtime reconciliation.",
|
||||
);
|
||||
@@ -815,6 +815,11 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
unavailableCronWarningEmitted = false;
|
||||
clearStartupCronRetry();
|
||||
}
|
||||
// Startup retries only probe cron availability; the exhausted retry path
|
||||
// re-enters runtime reconciliation so persistent failures still warn once.
|
||||
if (!cron && params.reason === "startup_retry") {
|
||||
return config;
|
||||
}
|
||||
if (params.reason === "runtime") {
|
||||
const now = Date.now();
|
||||
const withinThrottleWindow =
|
||||
@@ -852,12 +857,16 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
|
||||
return;
|
||||
}
|
||||
startupCronRetryAttempts += 1;
|
||||
void reconcileManagedDreamingCron({ reason: "runtime" })
|
||||
.then(() => {
|
||||
void reconcileManagedDreamingCron({ reason: "startup_retry" })
|
||||
.then(async () => {
|
||||
if (disposed || hasStartupCron()) {
|
||||
clearStartupCronRetry();
|
||||
return;
|
||||
}
|
||||
if (startupCronRetryAttempts >= STARTUP_CRON_RETRY_MAX_ATTEMPTS) {
|
||||
await reconcileManagedDreamingCron({ reason: "runtime" });
|
||||
return;
|
||||
}
|
||||
scheduleStartupCronRetry();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
|
||||
@@ -86,6 +86,10 @@ export function setMemoryWorkspaceDir(next: string): void {
|
||||
workspaceDir = next;
|
||||
}
|
||||
|
||||
export function setMemoryCustomStatus(next: Record<string, unknown> | undefined): void {
|
||||
customStatus = next;
|
||||
}
|
||||
|
||||
export function setMemorySearchImpl(next: SearchImpl): void {
|
||||
searchImpl = next;
|
||||
}
|
||||
@@ -130,6 +134,10 @@ export function getMemorySearchManagerMockCalls(): number {
|
||||
return getMemorySearchManagerMock.mock.calls.length;
|
||||
}
|
||||
|
||||
export function getMemorySyncMockCalls(): number {
|
||||
return stubManager.sync.mock.calls.length;
|
||||
}
|
||||
|
||||
export function getMemorySearchManagerMockConfigs(): unknown[] {
|
||||
return getMemorySearchManagerMock.mock.calls.map(([params]) => params.cfg);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export function resetEmbeddingMocks(): void {
|
||||
}
|
||||
|
||||
vi.mock("./embeddings.js", () => ({
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
createEmbeddingProvider: async () => ({
|
||||
requestedProvider: "openai",
|
||||
provider: {
|
||||
|
||||
@@ -146,6 +146,17 @@ export function resolveEmbeddingProviderFallbackModel(
|
||||
return adapter?.defaultModel ?? fallbackSourceModel;
|
||||
}
|
||||
|
||||
export function resolveEmbeddingProviderAdapterId(
|
||||
providerId: string,
|
||||
config?: MemoryEmbeddingProviderCreateOptions["config"],
|
||||
): string | undefined {
|
||||
try {
|
||||
return getAdapter(providerId, config).id;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWithAdapter(
|
||||
adapter: MemoryEmbeddingProviderAdapter,
|
||||
options: CreateEmbeddingProviderOptions,
|
||||
|
||||
@@ -13,6 +13,7 @@ import "./test-runtime-mocks.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
|
||||
import { LOCAL_EMBEDDING_WORKER_ERROR_CODES } from "./manager-local-worker-errors.js";
|
||||
import type { MemoryIndexMeta } from "./manager-reindex-state.js";
|
||||
import { closeMemoryIndexManagersForAgent, EMBEDDING_PROBE_CACHE_TTL_MS } from "./manager.js";
|
||||
import {
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
@@ -58,6 +59,14 @@ vi.mock("./embeddings.js", () => {
|
||||
providerId === "gemini" || providerId === "fallback-provider"
|
||||
? `${providerId}-embed`
|
||||
: fallbackSourceModel,
|
||||
resolveEmbeddingProviderAdapterId: (
|
||||
providerId: string,
|
||||
config?: {
|
||||
models?: {
|
||||
providers?: Record<string, { api?: string; baseUrl?: string; models?: unknown[] }>;
|
||||
};
|
||||
},
|
||||
) => config?.models?.providers?.[providerId]?.api ?? providerId,
|
||||
createEmbeddingProvider: async (options: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
@@ -77,7 +86,9 @@ vi.mock("./embeddings.js", () => {
|
||||
};
|
||||
}
|
||||
const providerId =
|
||||
options.provider === "gemini" || options.provider === "fallback-provider"
|
||||
options.provider === "gemini" ||
|
||||
options.provider === "fallback-provider" ||
|
||||
options.provider === "ollama"
|
||||
? options.provider
|
||||
: "mock";
|
||||
const model = options.model ?? "mock-embed";
|
||||
@@ -261,8 +272,9 @@ describe("memory index", () => {
|
||||
extraPaths?: string[];
|
||||
sources?: Array<"memory" | "sessions">;
|
||||
sessionMemory?: boolean;
|
||||
provider?: "openai" | "gemini" | "fallback-provider";
|
||||
provider?: string;
|
||||
fallback?: "none" | "gemini" | "fallback-provider";
|
||||
providerAliases?: NonNullable<NonNullable<TestCfg["models"]>["providers"]>;
|
||||
model?: string;
|
||||
outputDimensionality?: number;
|
||||
multimodal?: {
|
||||
@@ -302,6 +314,7 @@ describe("memory index", () => {
|
||||
},
|
||||
list: [{ id: "main", default: true }],
|
||||
},
|
||||
models: params.providerAliases ? { providers: params.providerAliases } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,9 +336,12 @@ describe("memory index", () => {
|
||||
return manager;
|
||||
}
|
||||
|
||||
async function getFreshManager(cfg: TestCfg): Promise<MemoryIndexManager> {
|
||||
async function getFreshManager(
|
||||
cfg: TestCfg,
|
||||
purpose?: "default" | "status" | "cli",
|
||||
): Promise<MemoryIndexManager> {
|
||||
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
|
||||
return await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
|
||||
return await getRequiredMemoryIndexManager({ cfg, agentId: "main", purpose });
|
||||
}
|
||||
|
||||
async function expectHybridKeywordSearchFindsMemory(cfg: TestCfg) {
|
||||
@@ -389,6 +405,406 @@ describe("memory index", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not full-reindex on search when existing metadata belongs to another provider", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-provider-cutover.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
model: "old-embed",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const nextCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "gemini",
|
||||
model: "new-embed",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const nextManager = await getFreshManager(nextCfg);
|
||||
try {
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({
|
||||
status: "mismatched",
|
||||
reason: "index was built for model old-embed, expected new-embed",
|
||||
});
|
||||
embedBatchCalls = 0;
|
||||
|
||||
const results = await nextManager.search("alpha");
|
||||
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(embedBatchCalls).toBe(0);
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
"# Log\nAlpha memory line changed.\nZebra memory line.",
|
||||
);
|
||||
await nextManager.sync({ reason: "watch" });
|
||||
|
||||
expect(embedBatchCalls).toBe(0);
|
||||
const stillPausedResults = await nextManager.search("alpha");
|
||||
expect(stillPausedResults).toStrictEqual([]);
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({
|
||||
status: "mismatched",
|
||||
reason: "index was built for model old-embed, expected new-embed",
|
||||
});
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps status clean when configured provider alias resolves to indexed adapter", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-provider-alias-status.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "ollama",
|
||||
model: "ollama-embed",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const aliasCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "ollama-west",
|
||||
providerAliases: {
|
||||
"ollama-west": {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
model: "ollama-embed",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const statusManager = await getFreshManager(aliasCfg, "status");
|
||||
try {
|
||||
const status = statusManager.status();
|
||||
|
||||
expect(status.dirty).toBe(false);
|
||||
expect(status.custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
} finally {
|
||||
await statusManager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not search stale rows when index metadata is missing", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-missing-meta-cutover.sqlite");
|
||||
const cfg = createCfg({
|
||||
storePath: dbPath,
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const oldManager = await getFreshManager(cfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
await fs.rm(path.join(memoryDir, "2026-01-12.md"));
|
||||
|
||||
const nextManager = await getFreshManager(cfg);
|
||||
try {
|
||||
(
|
||||
nextManager as unknown as {
|
||||
db: { exec: (sql: string) => void };
|
||||
}
|
||||
).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({
|
||||
status: "missing",
|
||||
reason: "index metadata is missing",
|
||||
});
|
||||
|
||||
const results = await nextManager.search("alpha");
|
||||
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({
|
||||
status: "missing",
|
||||
reason: "index metadata is missing",
|
||||
});
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not search stale provider rows after embeddings become unavailable", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-provider-unavailable-cutover.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
model: "semantic-embed",
|
||||
hybrid: { enabled: true, vectorWeight: 0.5, textWeight: 0.5 },
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
forceNoProvider = true;
|
||||
const nextManager = await getFreshManager(oldCfg);
|
||||
try {
|
||||
const results = await nextManager.search("alpha");
|
||||
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
expect(nextManager.status().custom?.indexIdentity).toMatchObject({
|
||||
status: "mismatched",
|
||||
});
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("clears dirty after sessions-only identity reindex", async () => {
|
||||
try {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-only-reindex"));
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "session-identity.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "session-identity",
|
||||
timestamp: "2026-04-07T15:24:04.113Z",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-07T15:25:04.113Z",
|
||||
content: [{ type: "text", text: "Session-only identity marker." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const dbPath = path.join(workspaceDir, "index-sessions-only-cutover.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["sessions"],
|
||||
sessionMemory: true,
|
||||
model: "old-embed",
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const nextCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["sessions"],
|
||||
sessionMemory: true,
|
||||
provider: "gemini",
|
||||
model: "new-embed",
|
||||
});
|
||||
const nextManager = await getFreshManager(nextCfg);
|
||||
try {
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
|
||||
await nextManager.sync({ reason: "test", force: true });
|
||||
|
||||
expect(nextManager.status().dirty).toBe(false);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("marks sessions-only indexes dirty when metadata is missing but chunks exist", async () => {
|
||||
try {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-sessions-missing-meta"));
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "session-missing-meta.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "session-missing-meta",
|
||||
timestamp: "2026-04-07T15:24:04.113Z",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-07T15:25:04.113Z",
|
||||
content: [{ type: "text", text: "Sessions missing metadata marker." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const dbPath = path.join(workspaceDir, "index-sessions-missing-meta.sqlite");
|
||||
const cfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["sessions"],
|
||||
sessionMemory: true,
|
||||
});
|
||||
const oldManager = await getFreshManager(cfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const nextManager = await getFreshManager(cfg);
|
||||
try {
|
||||
(
|
||||
nextManager as unknown as {
|
||||
db: { exec: (sql: string) => void };
|
||||
}
|
||||
).db.exec(`DELETE FROM meta WHERE key = 'memory_index_meta_v1'`);
|
||||
|
||||
const status = nextManager.status();
|
||||
|
||||
expect(status.dirty).toBe(true);
|
||||
expect(status.custom?.indexIdentity).toEqual({
|
||||
status: "missing",
|
||||
reason: "index metadata is missing",
|
||||
});
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps provider cutover vector search paused during targeted session sync", async () => {
|
||||
try {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-targeted-cutover"));
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "session-targeted-cutover.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "session-targeted-cutover",
|
||||
timestamp: "2026-04-07T15:24:04.113Z",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-07T15:25:04.113Z",
|
||||
content: [{ type: "text", text: "Targeted cutover marker." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const dbPath = path.join(workspaceDir, "index-targeted-session-cutover.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["memory", "sessions"],
|
||||
sessionMemory: true,
|
||||
model: "old-embed",
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const nextCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["memory", "sessions"],
|
||||
sessionMemory: true,
|
||||
provider: "gemini",
|
||||
model: "new-embed",
|
||||
});
|
||||
const nextManager = await getFreshManager(nextCfg);
|
||||
try {
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
embedBatchCalls = 0;
|
||||
|
||||
await nextManager.sync({ reason: "test", sessionFiles: [sessionFile] });
|
||||
|
||||
expect(embedBatchCalls).toBe(0);
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({
|
||||
status: "mismatched",
|
||||
reason: "index was built for model old-embed, expected new-embed",
|
||||
});
|
||||
const results = await nextManager.search("alpha");
|
||||
expect(results).toStrictEqual([]);
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves memory dirty events raised during session identity reindex", async () => {
|
||||
try {
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state-dirty-during-session"));
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "session-dirty-during-reindex.jsonl"),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
id: "session-dirty-during-reindex",
|
||||
timestamp: "2026-04-07T15:24:04.113Z",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-07T15:25:04.113Z",
|
||||
content: [{ type: "text", text: "Dirty during session marker." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const dbPath = path.join(workspaceDir, "index-dirty-during-session.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["memory", "sessions"],
|
||||
sessionMemory: true,
|
||||
model: "old-embed",
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const nextCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
sources: ["memory", "sessions"],
|
||||
sessionMemory: true,
|
||||
provider: "gemini",
|
||||
model: "new-embed",
|
||||
});
|
||||
const nextManager = await getFreshManager(nextCfg);
|
||||
try {
|
||||
const fields = nextManager as unknown as {
|
||||
dirty: boolean;
|
||||
syncSessionFiles: (params: unknown) => Promise<void>;
|
||||
};
|
||||
const syncSessionFiles = fields.syncSessionFiles.bind(nextManager);
|
||||
fields.syncSessionFiles = async (params) => {
|
||||
fields.dirty = true;
|
||||
await syncSessionFiles(params);
|
||||
};
|
||||
|
||||
await nextManager.sync({ reason: "test", force: true });
|
||||
|
||||
expect(nextManager.status().dirty).toBe(true);
|
||||
expect(nextManager.status().custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
} finally {
|
||||
await nextManager.close?.();
|
||||
}
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes embedding providers when memory index managers close", async () => {
|
||||
const cfg = createCfg({
|
||||
storePath: indexMainPath,
|
||||
@@ -593,7 +1009,7 @@ describe("memory index", () => {
|
||||
waitForEmbeddingRetry: (delayMs: number, action: string) => Promise<void>;
|
||||
}
|
||||
).provider = {
|
||||
id: "openai",
|
||||
id: "mock",
|
||||
model: "mock-embed",
|
||||
embedQuery: async () => {
|
||||
queryCalls += 1;
|
||||
@@ -637,7 +1053,7 @@ describe("memory index", () => {
|
||||
};
|
||||
}
|
||||
).provider = {
|
||||
id: "openai",
|
||||
id: "mock",
|
||||
model: "mock-embed",
|
||||
embedQuery: async () => {
|
||||
queryCalls += 1;
|
||||
@@ -696,6 +1112,76 @@ describe("memory index", () => {
|
||||
expect(status.vector?.available).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks older vector indexes dirty after vector store probing", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-vector-missing-dims.sqlite");
|
||||
const legacyCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "gemini",
|
||||
vectorEnabled: false,
|
||||
});
|
||||
const legacyManager = await getFreshManager(legacyCfg);
|
||||
await legacyManager.sync({ reason: "test", force: true });
|
||||
await legacyManager.close?.();
|
||||
|
||||
const cfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "gemini",
|
||||
vectorEnabled: true,
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
try {
|
||||
const metaAccess = manager as unknown as {
|
||||
readMeta(): MemoryIndexMeta | null;
|
||||
};
|
||||
const meta = metaAccess.readMeta();
|
||||
if (!meta) {
|
||||
throw new Error("expected index metadata");
|
||||
}
|
||||
expect(meta.vectorDims).toBeUndefined();
|
||||
|
||||
await manager.probeVectorStoreAvailability?.();
|
||||
const status = manager.status();
|
||||
|
||||
expect(status.dirty).toBe(true);
|
||||
expect(status.custom?.indexIdentity).toEqual({
|
||||
status: "mismatched",
|
||||
reason: "index vector dimensions are missing",
|
||||
});
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps empty vector indexes clean after vector store probing", async () => {
|
||||
await fs.rm(path.join(memoryDir, "2026-01-12.md"));
|
||||
const dbPath = path.join(workspaceDir, "index-empty-vector.sqlite");
|
||||
const legacyCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "gemini",
|
||||
vectorEnabled: false,
|
||||
});
|
||||
const legacyManager = await getFreshManager(legacyCfg);
|
||||
await legacyManager.sync({ reason: "test", force: true });
|
||||
await legacyManager.close?.();
|
||||
|
||||
const cfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "gemini",
|
||||
vectorEnabled: true,
|
||||
});
|
||||
const manager = await getFreshManager(cfg, "status");
|
||||
try {
|
||||
await manager.probeVectorStoreAvailability?.();
|
||||
|
||||
const status = manager.status();
|
||||
|
||||
expect(status.dirty).toBe(false);
|
||||
expect(status.custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("caches embedding probe readiness across transient status managers", async () => {
|
||||
const cfg = createCfg({ storePath: path.join(workspaceDir, "index-probe-cache.sqlite") });
|
||||
const first = requireManager(
|
||||
@@ -778,7 +1264,7 @@ describe("memory index", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("activates configured fallback when local embeddings degrade during search", async () => {
|
||||
it("does not activate fallback during search when index identity is already mismatched", async () => {
|
||||
const cfg = createCfg({
|
||||
storePath: path.join(workspaceDir, "index-search-degraded-fallback.sqlite"),
|
||||
fallback: "fallback-provider",
|
||||
@@ -810,21 +1296,68 @@ describe("memory index", () => {
|
||||
|
||||
const results = await manager.search("alpha");
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
const resultKeys = results.map(
|
||||
(result) => `${result.source}:${result.path}:${result.startLine}:${result.endLine}`,
|
||||
);
|
||||
expect(new Set(resultKeys).size).toBe(resultKeys.length);
|
||||
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
|
||||
"fallback-provider",
|
||||
);
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(providerCalls.slice(callsBeforeSearch)).toStrictEqual([]);
|
||||
expect(
|
||||
(
|
||||
manager as unknown as {
|
||||
provider: { id: string } | null;
|
||||
}
|
||||
).provider?.id,
|
||||
).toBe("fallback-provider");
|
||||
).toBe("local");
|
||||
});
|
||||
|
||||
it("rebuilds with fallback provider during explicit identity repair", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-cli-fallback-identity-repair.sqlite");
|
||||
const oldCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
model: "old-embed",
|
||||
});
|
||||
const oldManager = await getFreshManager(oldCfg);
|
||||
await oldManager.sync({ reason: "test", force: true });
|
||||
await oldManager.close?.();
|
||||
|
||||
const cfg = createCfg({
|
||||
storePath: dbPath,
|
||||
model: "new-embed",
|
||||
fallback: "fallback-provider",
|
||||
});
|
||||
const manager = await getFreshManager(cfg);
|
||||
try {
|
||||
expect(manager.status().dirty).toBe(true);
|
||||
const fields = manager as unknown as {
|
||||
providerInitialized: boolean;
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
};
|
||||
fields.providerInitialized = true;
|
||||
fields.provider = {
|
||||
id: "mock",
|
||||
model: "new-embed",
|
||||
embedQuery: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
embedBatch: async () => {
|
||||
throw createLocalWorkerExitError();
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
await manager.sync({ reason: "cli" });
|
||||
|
||||
expect(manager.status().dirty).toBe(false);
|
||||
expect(manager.status().provider).toBe("fallback-provider");
|
||||
expect(manager.status().model).toBe("fallback-provider-embed");
|
||||
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
await expect(manager.search("alpha")).resolves.not.toStrictEqual([]);
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("activates configured fallback after probe-time local degradation", async () => {
|
||||
@@ -866,7 +1399,7 @@ describe("memory index", () => {
|
||||
|
||||
const results = await manager.search("alpha");
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results).toStrictEqual([]);
|
||||
expect(providerCalls.slice(callsBeforeSearch).map((call) => call.provider)).toContain(
|
||||
"fallback-provider",
|
||||
);
|
||||
@@ -879,6 +1412,73 @@ describe("memory index", () => {
|
||||
).toBe("fallback-provider");
|
||||
});
|
||||
|
||||
it("clears identity dirty after status resolves the indexed fallback provider", async () => {
|
||||
const dbPath = path.join(workspaceDir, "index-status-fallback-identity.sqlite");
|
||||
const indexedCfg = createCfg({
|
||||
storePath: dbPath,
|
||||
provider: "fallback-provider",
|
||||
model: "new-embed",
|
||||
});
|
||||
const indexedManager = await getFreshManager(indexedCfg);
|
||||
await indexedManager.sync({ reason: "test", force: true });
|
||||
await indexedManager.close?.();
|
||||
|
||||
const cfg = createCfg({
|
||||
storePath: dbPath,
|
||||
fallback: "fallback-provider",
|
||||
model: "new-embed",
|
||||
});
|
||||
const { getRequiredMemoryIndexManager } = await import("./test-manager-helpers.js");
|
||||
const manager = await getRequiredMemoryIndexManager({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
purpose: "status",
|
||||
});
|
||||
try {
|
||||
expect(manager.status().dirty).toBe(true);
|
||||
|
||||
const fields = manager as unknown as {
|
||||
provider: {
|
||||
id: string;
|
||||
model: string;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
providerInitialized: boolean;
|
||||
providerRuntime: {
|
||||
id: string;
|
||||
cacheKeyData: Record<string, unknown>;
|
||||
};
|
||||
providerKey: string;
|
||||
computeProviderKey: () => string;
|
||||
};
|
||||
fields.provider = {
|
||||
id: "fallback-provider",
|
||||
model: "new-embed",
|
||||
embedQuery: async () => [1, 0, 0, 0],
|
||||
embedBatch: async (texts) => texts.map(() => [1, 0, 0, 0]),
|
||||
close: async () => {},
|
||||
};
|
||||
fields.providerRuntime = {
|
||||
id: "fallback-provider",
|
||||
cacheKeyData: {
|
||||
provider: "fallback-provider",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
model: "new-embed",
|
||||
headers: [],
|
||||
},
|
||||
};
|
||||
fields.providerInitialized = true;
|
||||
fields.providerKey = fields.computeProviderKey();
|
||||
|
||||
expect(manager.status().dirty).toBe(false);
|
||||
expect(manager.status().custom?.indexIdentity).toEqual({ status: "valid" });
|
||||
} finally {
|
||||
await manager.close?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("streams embedding cache rows during safe reindex", async () => {
|
||||
vi.stubEnv("OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX", "0");
|
||||
type EmbeddingCacheRow = {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveConfiguredScopeHash,
|
||||
resolveConfiguredSourcesForMeta,
|
||||
shouldRunFullMemoryReindex,
|
||||
resolveMemoryIndexIdentityState,
|
||||
isMemoryIndexIdentityDirty,
|
||||
type MemoryIndexMeta,
|
||||
} from "./manager-reindex-state.js";
|
||||
|
||||
@@ -21,16 +22,18 @@ function createMeta(overrides: Partial<MemoryIndexMeta> = {}): MemoryIndexMeta {
|
||||
};
|
||||
}
|
||||
|
||||
function createFullReindexParams(
|
||||
function createIdentityParams(
|
||||
overrides: {
|
||||
meta?: MemoryIndexMeta | null;
|
||||
provider?: { id: string; model: string } | null;
|
||||
providerKey?: string;
|
||||
providerKeyKnown?: boolean;
|
||||
configuredSources?: MemorySource[];
|
||||
configuredScopeHash?: string;
|
||||
chunkTokens?: number;
|
||||
chunkOverlap?: number;
|
||||
vectorReady?: boolean;
|
||||
hasIndexedChunks?: boolean;
|
||||
ftsTokenizer?: string;
|
||||
} = {},
|
||||
) {
|
||||
@@ -43,26 +46,41 @@ function createFullReindexParams(
|
||||
chunkTokens: 4000,
|
||||
chunkOverlap: 0,
|
||||
vectorReady: false,
|
||||
hasIndexedChunks: true,
|
||||
ftsTokenizer: "unicode61",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("memory reindex state", () => {
|
||||
it("requires a full reindex when the embedding model changes", () => {
|
||||
it("marks identity dirty when the embedding model changes", () => {
|
||||
expect(
|
||||
shouldRunFullMemoryReindex(
|
||||
createFullReindexParams({
|
||||
isMemoryIndexIdentityDirty(
|
||||
createIdentityParams({
|
||||
provider: { id: "openai", model: "mock-embed-v2" },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a full reindex when the provider cache key changes", () => {
|
||||
it("returns a mismatch reason when provider identity changes", () => {
|
||||
expect(
|
||||
shouldRunFullMemoryReindex(
|
||||
createFullReindexParams({
|
||||
resolveMemoryIndexIdentityState(
|
||||
createIdentityParams({
|
||||
provider: { id: "ollama", model: "mock-embed-v1" },
|
||||
providerKey: "provider-key-ollama",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
status: "mismatched",
|
||||
reason: "index was built for provider openai, expected ollama",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks identity dirty when the provider cache key changes", () => {
|
||||
expect(
|
||||
isMemoryIndexIdentityDirty(
|
||||
createIdentityParams({
|
||||
provider: { id: "gemini", model: "gemini-embedding-2-preview" },
|
||||
providerKey: "provider-key-dims-768",
|
||||
meta: createMeta({
|
||||
@@ -75,7 +93,30 @@ describe("memory reindex state", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a full reindex when extraPaths change", () => {
|
||||
it("can defer provider key comparison until provider initialization", () => {
|
||||
expect(
|
||||
resolveMemoryIndexIdentityState(
|
||||
createIdentityParams({
|
||||
providerKey: undefined,
|
||||
providerKeyKnown: false,
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "valid" });
|
||||
});
|
||||
|
||||
it("does not mark identity dirty for vector dimensions before chunks exist", () => {
|
||||
expect(
|
||||
resolveMemoryIndexIdentityState(
|
||||
createIdentityParams({
|
||||
vectorReady: true,
|
||||
hasIndexedChunks: false,
|
||||
meta: createMeta({ vectorDims: undefined }),
|
||||
}),
|
||||
),
|
||||
).toEqual({ status: "valid" });
|
||||
});
|
||||
|
||||
it("marks identity dirty when extraPaths change", () => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const firstScopeHash = resolveConfiguredScopeHash({
|
||||
workspaceDir,
|
||||
@@ -97,8 +138,8 @@ describe("memory reindex state", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldRunFullMemoryReindex(
|
||||
createFullReindexParams({
|
||||
isMemoryIndexIdentityDirty(
|
||||
createIdentityParams({
|
||||
meta: createMeta({ scopeHash: firstScopeHash }),
|
||||
configuredScopeHash: secondScopeHash,
|
||||
}),
|
||||
@@ -106,17 +147,17 @@ describe("memory reindex state", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a full reindex when configured sources add sessions", () => {
|
||||
it("marks identity dirty when configured sources add sessions", () => {
|
||||
expect(
|
||||
shouldRunFullMemoryReindex(
|
||||
createFullReindexParams({
|
||||
isMemoryIndexIdentityDirty(
|
||||
createIdentityParams({
|
||||
configuredSources: ["memory", "sessions"],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires a full reindex when multimodal settings change", () => {
|
||||
it("marks identity dirty when multimodal settings change", () => {
|
||||
const workspaceDir = "/tmp/workspace";
|
||||
const firstScopeHash = resolveConfiguredScopeHash({
|
||||
workspaceDir,
|
||||
@@ -138,8 +179,8 @@ describe("memory reindex state", () => {
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldRunFullMemoryReindex(
|
||||
createFullReindexParams({
|
||||
isMemoryIndexIdentityDirty(
|
||||
createIdentityParams({
|
||||
meta: createMeta({ scopeHash: firstScopeHash }),
|
||||
configuredScopeHash: secondScopeHash,
|
||||
}),
|
||||
@@ -149,8 +190,8 @@ describe("memory reindex state", () => {
|
||||
|
||||
it("keeps older indexes with missing sources compatible with memory-only config", () => {
|
||||
expect(
|
||||
shouldRunFullMemoryReindex(
|
||||
createFullReindexParams({
|
||||
isMemoryIndexIdentityDirty(
|
||||
createIdentityParams({
|
||||
meta: createMeta({ sources: undefined }),
|
||||
configuredSources: resolveConfiguredSourcesForMeta(new Set(["memory"])),
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,19 @@ export type MemoryIndexMeta = {
|
||||
ftsTokenizer?: string;
|
||||
};
|
||||
|
||||
export type MemoryIndexIdentityState =
|
||||
| {
|
||||
status: "valid";
|
||||
}
|
||||
| {
|
||||
status: "missing";
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
status: "mismatched";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function resolveConfiguredSourcesForMeta(sources: Iterable<MemorySource>): MemorySource[] {
|
||||
const normalized = Array.from(sources)
|
||||
.filter((source): source is MemorySource => source === "memory" || source === "sessions")
|
||||
@@ -73,31 +86,93 @@ export function resolveConfiguredScopeHash(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRunFullMemoryReindex(params: {
|
||||
export function isMemoryIndexIdentityDirty(params: {
|
||||
meta: MemoryIndexMeta | null;
|
||||
provider: { id: string; model: string } | null;
|
||||
providerKey?: string;
|
||||
providerKeyKnown?: boolean;
|
||||
configuredSources: MemorySource[];
|
||||
configuredScopeHash: string;
|
||||
chunkTokens: number;
|
||||
chunkOverlap: number;
|
||||
vectorReady: boolean;
|
||||
hasIndexedChunks?: boolean;
|
||||
ftsTokenizer: string;
|
||||
}): boolean {
|
||||
return resolveMemoryIndexIdentityState(params).status !== "valid";
|
||||
}
|
||||
|
||||
export function resolveMemoryIndexIdentityState(params: {
|
||||
meta: MemoryIndexMeta | null;
|
||||
provider: { id: string; model: string } | null;
|
||||
providerKey?: string;
|
||||
providerKeyKnown?: boolean;
|
||||
configuredSources: MemorySource[];
|
||||
configuredScopeHash: string;
|
||||
chunkTokens: number;
|
||||
chunkOverlap: number;
|
||||
vectorReady: boolean;
|
||||
hasIndexedChunks?: boolean;
|
||||
ftsTokenizer: string;
|
||||
}): MemoryIndexIdentityState {
|
||||
const { meta } = params;
|
||||
return (
|
||||
!meta ||
|
||||
(params.provider ? meta.model !== params.provider.model : meta.model !== "fts-only") ||
|
||||
(params.provider ? meta.provider !== params.provider.id : meta.provider !== "none") ||
|
||||
meta.providerKey !== params.providerKey ||
|
||||
if (!meta) {
|
||||
return { status: "missing", reason: "index metadata is missing" };
|
||||
}
|
||||
const expectedModel = params.provider ? params.provider.model : "fts-only";
|
||||
if (meta.model !== expectedModel) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: `index was built for model ${meta.model}, expected ${expectedModel}`,
|
||||
};
|
||||
}
|
||||
const expectedProvider = params.provider ? params.provider.id : "none";
|
||||
if (meta.provider !== expectedProvider) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: `index was built for provider ${meta.provider}, expected ${expectedProvider}`,
|
||||
};
|
||||
}
|
||||
if (params.providerKeyKnown !== false && meta.providerKey !== params.providerKey) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: "index provider settings changed",
|
||||
};
|
||||
}
|
||||
if (
|
||||
configuredMetaSourcesDiffer({
|
||||
meta,
|
||||
configuredSources: params.configuredSources,
|
||||
}) ||
|
||||
meta.scopeHash !== params.configuredScopeHash ||
|
||||
meta.chunkTokens !== params.chunkTokens ||
|
||||
meta.chunkOverlap !== params.chunkOverlap ||
|
||||
(params.vectorReady && !meta.vectorDims) ||
|
||||
(meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer
|
||||
);
|
||||
})
|
||||
) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: "index sources changed",
|
||||
};
|
||||
}
|
||||
if (meta.scopeHash !== params.configuredScopeHash) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: "index scope changed",
|
||||
};
|
||||
}
|
||||
if (meta.chunkTokens !== params.chunkTokens || meta.chunkOverlap !== params.chunkOverlap) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: "index chunking changed",
|
||||
};
|
||||
}
|
||||
if (params.vectorReady && params.hasIndexedChunks !== false && !meta.vectorDims) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: "index vector dimensions are missing",
|
||||
};
|
||||
}
|
||||
if ((meta.ftsTokenizer ?? "unicode61") !== params.ftsTokenizer) {
|
||||
return {
|
||||
status: "mismatched",
|
||||
reason: "index FTS tokenizer changed",
|
||||
};
|
||||
}
|
||||
return { status: "valid" };
|
||||
}
|
||||
|
||||
@@ -573,7 +573,11 @@ describe("searchVector sqlite-vec KNN", () => {
|
||||
|
||||
function insertFallbackChunk(
|
||||
db: InstanceType<typeof DatabaseSync>,
|
||||
params: { id: string; model: string; vector: number[] },
|
||||
params: {
|
||||
id: string;
|
||||
model: string;
|
||||
vector: number[];
|
||||
},
|
||||
): void {
|
||||
db.prepare(
|
||||
"INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
|
||||
@@ -28,6 +28,17 @@ describe("memory manager status state", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("marks status-only managers dirty when index identity mismatches", () => {
|
||||
expect(
|
||||
resolveInitialMemoryDirty({
|
||||
hasMemorySource: false,
|
||||
statusOnly: true,
|
||||
hasIndexedMeta: true,
|
||||
indexIdentityMismatched: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the requested provider before provider initialization", () => {
|
||||
expect(
|
||||
resolveStatusProviderInfo({
|
||||
|
||||
@@ -27,8 +27,12 @@ export function resolveInitialMemoryDirty(params: {
|
||||
hasMemorySource: boolean;
|
||||
statusOnly: boolean;
|
||||
hasIndexedMeta: boolean;
|
||||
indexIdentityMismatched?: boolean;
|
||||
}): boolean {
|
||||
return params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true);
|
||||
return (
|
||||
Boolean(params.indexIdentityMismatched) ||
|
||||
(params.hasMemorySource && (params.statusOnly ? !params.hasIndexedMeta : true))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveStatusProviderInfo(params: {
|
||||
|
||||
@@ -38,6 +38,7 @@ import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
createEmbeddingProvider,
|
||||
resolveEmbeddingProviderAdapterId,
|
||||
type EmbeddingProvider,
|
||||
type EmbeddingProviderId,
|
||||
type EmbeddingProviderRuntime,
|
||||
@@ -54,8 +55,9 @@ import {
|
||||
import {
|
||||
resolveConfiguredScopeHash,
|
||||
resolveConfiguredSourcesForMeta,
|
||||
shouldRunFullMemoryReindex,
|
||||
resolveMemoryIndexIdentityState,
|
||||
type MemoryIndexMeta,
|
||||
type MemoryIndexIdentityState,
|
||||
} from "./manager-reindex-state.js";
|
||||
import { shouldSyncSessionsForReindex } from "./manager-session-reindex.js";
|
||||
import {
|
||||
@@ -67,7 +69,10 @@ import {
|
||||
loadMemorySourceFileState,
|
||||
resolveMemorySourceExistingHash,
|
||||
} from "./manager-source-state.js";
|
||||
import { runMemoryTargetedSessionSync } from "./manager-targeted-sync.js";
|
||||
import {
|
||||
markMemoryTargetSessionFilesDirty,
|
||||
runMemoryTargetedSessionSync,
|
||||
} from "./manager-targeted-sync.js";
|
||||
import {
|
||||
recordMemoryWatchEventPath,
|
||||
settleMemoryWatchEventPaths,
|
||||
@@ -269,6 +274,65 @@ export abstract class MemoryManagerSyncOps {
|
||||
options: { source: MemorySource; content?: string },
|
||||
): Promise<void>;
|
||||
|
||||
protected hasIndexedChunks(): boolean {
|
||||
const row = this.db.prepare(`SELECT 1 as found FROM chunks LIMIT 1`).get() as
|
||||
| { found?: number }
|
||||
| undefined;
|
||||
return row?.found === 1;
|
||||
}
|
||||
|
||||
protected resolveCurrentIndexIdentityState(params?: {
|
||||
meta?: MemoryIndexMeta | null;
|
||||
provider?: { id: string; model: string } | null;
|
||||
providerKeyKnown?: boolean;
|
||||
vectorReady?: boolean;
|
||||
hasIndexedChunks?: boolean;
|
||||
}): MemoryIndexIdentityState {
|
||||
const hasProviderOverride = params && "provider" in params;
|
||||
const configuredProvider =
|
||||
this.settings.provider === "none"
|
||||
? null
|
||||
: {
|
||||
id:
|
||||
resolveEmbeddingProviderAdapterId(this.settings.provider, this.cfg) ??
|
||||
this.settings.provider,
|
||||
model: this.settings.model,
|
||||
};
|
||||
const provider = hasProviderOverride
|
||||
? params.provider!
|
||||
: this.provider
|
||||
? { id: this.provider.id, model: this.provider.model }
|
||||
: configuredProvider;
|
||||
const vectorReady =
|
||||
params && "vectorReady" in params
|
||||
? Boolean(params.vectorReady)
|
||||
: this.vector.available === true;
|
||||
return resolveMemoryIndexIdentityState({
|
||||
meta: params && "meta" in params ? params.meta! : this.readMeta(),
|
||||
provider,
|
||||
providerKey: params?.providerKeyKnown === false ? undefined : (this.providerKey ?? undefined),
|
||||
providerKeyKnown: params?.providerKeyKnown,
|
||||
configuredSources: resolveConfiguredSourcesForMeta(this.sources),
|
||||
configuredScopeHash: resolveConfiguredScopeHash({
|
||||
workspaceDir: this.workspaceDir,
|
||||
extraPaths: this.settings.extraPaths,
|
||||
multimodal: {
|
||||
enabled: this.settings.multimodal.enabled,
|
||||
modalities: this.settings.multimodal.modalities,
|
||||
maxFileBytes: this.settings.multimodal.maxFileBytes,
|
||||
},
|
||||
}),
|
||||
chunkTokens: this.settings.chunking.tokens,
|
||||
chunkOverlap: this.settings.chunking.overlap,
|
||||
vectorReady,
|
||||
hasIndexedChunks:
|
||||
params && "hasIndexedChunks" in params
|
||||
? Boolean(params.hasIndexedChunks)
|
||||
: this.hasIndexedChunks(),
|
||||
ftsTokenizer: this.settings.store.fts.tokenizer,
|
||||
});
|
||||
}
|
||||
|
||||
protected resetVectorState(): void {
|
||||
this.vectorReady = null;
|
||||
this.vector.available = null;
|
||||
@@ -1691,60 +1755,69 @@ export abstract class MemoryManagerSyncOps {
|
||||
}
|
||||
const vectorReady = await this.ensureVectorReady();
|
||||
const meta = this.readMeta();
|
||||
const configuredSources = resolveConfiguredSourcesForMeta(this.sources);
|
||||
const configuredScopeHash = resolveConfiguredScopeHash({
|
||||
workspaceDir: this.workspaceDir,
|
||||
extraPaths: this.settings.extraPaths,
|
||||
multimodal: {
|
||||
enabled: this.settings.multimodal.enabled,
|
||||
modalities: this.settings.multimodal.modalities,
|
||||
maxFileBytes: this.settings.multimodal.maxFileBytes,
|
||||
},
|
||||
});
|
||||
const targetSessionFiles = this.normalizeTargetSessionFiles(params?.sessionFiles);
|
||||
const hasTargetSessionFiles = targetSessionFiles !== null;
|
||||
if (params?.reason === "cli" && !params.force && !hasTargetSessionFiles) {
|
||||
await this.markSessionStartupCatchupDirtyFiles();
|
||||
}
|
||||
const targetedSessionSync = await runMemoryTargetedSessionSync({
|
||||
hasSessionSource: this.sources.has("sessions"),
|
||||
targetSessionFiles,
|
||||
reason: params?.reason,
|
||||
progress: progress ?? undefined,
|
||||
useUnsafeReindex:
|
||||
process.env.OPENCLAW_TEST_FAST === "1" &&
|
||||
process.env.OPENCLAW_TEST_MEMORY_UNSAFE_REINDEX === "1",
|
||||
sessionsDirtyFiles: this.sessionsDirtyFiles,
|
||||
syncSessionFiles: async (targetedParams) => {
|
||||
await this.syncSessionFiles(targetedParams);
|
||||
},
|
||||
shouldFallbackOnError: (err) => this.shouldFallbackOnError(err),
|
||||
activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason),
|
||||
runSafeReindex: async (reindexParams) => {
|
||||
await this.runSafeReindex(reindexParams);
|
||||
},
|
||||
runUnsafeReindex: async (reindexParams) => {
|
||||
await this.runUnsafeReindex(reindexParams);
|
||||
},
|
||||
const indexIdentity = resolveMemoryIndexIdentityState({
|
||||
meta,
|
||||
// Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up.
|
||||
provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null,
|
||||
providerKey: this.providerKey ?? undefined,
|
||||
configuredSources: resolveConfiguredSourcesForMeta(this.sources),
|
||||
configuredScopeHash: resolveConfiguredScopeHash({
|
||||
workspaceDir: this.workspaceDir,
|
||||
extraPaths: this.settings.extraPaths,
|
||||
multimodal: {
|
||||
enabled: this.settings.multimodal.enabled,
|
||||
modalities: this.settings.multimodal.modalities,
|
||||
maxFileBytes: this.settings.multimodal.maxFileBytes,
|
||||
},
|
||||
}),
|
||||
chunkTokens: this.settings.chunking.tokens,
|
||||
chunkOverlap: this.settings.chunking.overlap,
|
||||
vectorReady,
|
||||
hasIndexedChunks: this.hasIndexedChunks(),
|
||||
ftsTokenizer: this.settings.store.fts.tokenizer,
|
||||
});
|
||||
if (targetedSessionSync.handled) {
|
||||
this.sessionsDirty = targetedSessionSync.sessionsDirty;
|
||||
return;
|
||||
}
|
||||
const hasIndexedChunks = this.hasIndexedChunks();
|
||||
const needsInitialIndex = indexIdentity.status !== "valid" && !hasIndexedChunks;
|
||||
const needsExplicitIdentityReindex =
|
||||
params?.reason === "cli" && indexIdentity.status !== "valid" && !hasTargetSessionFiles;
|
||||
const needsFullReindex =
|
||||
(params?.force && !hasTargetSessionFiles) ||
|
||||
shouldRunFullMemoryReindex({
|
||||
meta,
|
||||
// Also detects provider→FTS-only transitions so orphaned old-model FTS rows are cleaned up.
|
||||
provider: this.provider ? { id: this.provider.id, model: this.provider.model } : null,
|
||||
providerKey: this.providerKey ?? undefined,
|
||||
configuredSources,
|
||||
configuredScopeHash,
|
||||
chunkTokens: this.settings.chunking.tokens,
|
||||
chunkOverlap: this.settings.chunking.overlap,
|
||||
vectorReady,
|
||||
ftsTokenizer: this.settings.store.fts.tokenizer,
|
||||
needsInitialIndex ||
|
||||
needsExplicitIdentityReindex;
|
||||
if (indexIdentity.status !== "valid" && !needsFullReindex) {
|
||||
this.dirty = true;
|
||||
const sessionsDirty = markMemoryTargetSessionFilesDirty({
|
||||
sessionsDirtyFiles: this.sessionsDirtyFiles,
|
||||
targetSessionFiles,
|
||||
});
|
||||
if (sessionsDirty) {
|
||||
this.sessionsDirty = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!needsFullReindex) {
|
||||
const targetedSessionSync = await runMemoryTargetedSessionSync({
|
||||
hasSessionSource: this.sources.has("sessions"),
|
||||
targetSessionFiles,
|
||||
reason: params?.reason,
|
||||
progress: progress ?? undefined,
|
||||
sessionsDirtyFiles: this.sessionsDirtyFiles,
|
||||
syncSessionFiles: async (targetedParams) => {
|
||||
await this.syncSessionFiles(targetedParams);
|
||||
},
|
||||
shouldFallbackOnError: (err) => this.shouldFallbackOnError(err),
|
||||
activateFallbackProvider: async (reason) => await this.activateFallbackProvider(reason),
|
||||
});
|
||||
if (targetedSessionSync.handled) {
|
||||
this.sessionsDirty = targetedSessionSync.sessionsDirty;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (needsFullReindex) {
|
||||
if (
|
||||
@@ -1794,20 +1867,17 @@ export abstract class MemoryManagerSyncOps {
|
||||
const activated =
|
||||
this.shouldFallbackOnError(err) && (await this.activateFallbackProvider(reason));
|
||||
if (activated) {
|
||||
await this.runSafeReindex({
|
||||
reason: params?.reason ?? "fallback",
|
||||
force: true,
|
||||
progress: progress ?? undefined,
|
||||
});
|
||||
if (needsFullReindex && !hasTargetSessionFiles) {
|
||||
await this.runSafeReindex({
|
||||
reason: params?.reason ?? "fallback",
|
||||
force: true,
|
||||
progress: progress ?? undefined,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.provider && this.fts.enabled && this.shouldFallbackOnError(err)) {
|
||||
log.warn(`memory embeddings unavailable; rebuilding lexical memory index only: ${reason}`);
|
||||
await this.runSafeReindex({
|
||||
reason: params?.reason ?? "embedding-degraded",
|
||||
force: true,
|
||||
progress: progress ?? undefined,
|
||||
});
|
||||
log.warn(`memory embeddings unavailable; leaving memory index dirty: ${reason}`);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
@@ -1965,6 +2035,9 @@ export abstract class MemoryManagerSyncOps {
|
||||
} else {
|
||||
this.sessionsDirty = false;
|
||||
}
|
||||
if (!shouldSyncMemory) {
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
const meta: MemoryIndexMeta = {
|
||||
model: this.provider?.model ?? "fts-only",
|
||||
@@ -2045,6 +2118,9 @@ export abstract class MemoryManagerSyncOps {
|
||||
} else {
|
||||
this.sessionsDirty = false;
|
||||
}
|
||||
if (!shouldSyncMemory) {
|
||||
this.dirty = false;
|
||||
}
|
||||
|
||||
const nextMeta: MemoryIndexMeta = {
|
||||
model: this.provider?.model ?? "fts-only",
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => {
|
||||
});
|
||||
|
||||
vi.mock("./embeddings.js", () => ({
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
createEmbeddingProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearMemorySyncedSessionFiles,
|
||||
markMemoryTargetSessionFilesDirty,
|
||||
runMemoryTargetedSessionSync,
|
||||
} from "./manager-targeted-sync.js";
|
||||
|
||||
@@ -18,61 +19,48 @@ describe("memory targeted session sync", () => {
|
||||
expect(sessionsDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("runs a full reindex after fallback activates during targeted sync", async () => {
|
||||
const activateFallbackProvider = vi.fn(async () => true);
|
||||
const runSafeReindex = vi.fn(async () => {});
|
||||
const runUnsafeReindex = vi.fn(async () => {});
|
||||
it("marks target sessions dirty while identity sync is paused", () => {
|
||||
const targetSessionPath = "/tmp/paused-target.jsonl";
|
||||
const sessionsDirtyFiles = new Set(["/tmp/other-dirty.jsonl"]);
|
||||
|
||||
await runMemoryTargetedSessionSync({
|
||||
const sessionsDirty = markMemoryTargetSessionFilesDirty({
|
||||
sessionsDirtyFiles,
|
||||
targetSessionFiles: [targetSessionPath],
|
||||
});
|
||||
|
||||
expect(sessionsDirty).toBe(true);
|
||||
expect(sessionsDirtyFiles.has(targetSessionPath)).toBe(true);
|
||||
expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves targeted sessions dirty after fallback activates during targeted sync", async () => {
|
||||
const activateFallbackProvider = vi.fn(async () => true);
|
||||
const syncSessionFiles = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("embedding backend failed"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const sessionsDirtyFiles = new Set(["/tmp/targeted-fallback.jsonl", "/tmp/other-dirty.jsonl"]);
|
||||
|
||||
const result = await runMemoryTargetedSessionSync({
|
||||
hasSessionSource: true,
|
||||
targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]),
|
||||
reason: "post-compaction",
|
||||
progress: undefined,
|
||||
useUnsafeReindex: false,
|
||||
sessionsDirtyFiles: new Set(),
|
||||
syncSessionFiles: async () => {
|
||||
throw new Error("embedding backend failed");
|
||||
},
|
||||
sessionsDirtyFiles,
|
||||
syncSessionFiles,
|
||||
shouldFallbackOnError: () => true,
|
||||
activateFallbackProvider,
|
||||
runSafeReindex,
|
||||
runUnsafeReindex,
|
||||
});
|
||||
|
||||
expect(activateFallbackProvider).toHaveBeenCalledWith("embedding backend failed");
|
||||
expect(runSafeReindex).toHaveBeenCalledWith({
|
||||
reason: "post-compaction",
|
||||
force: true,
|
||||
expect(syncSessionFiles).toHaveBeenCalledTimes(1);
|
||||
expect(syncSessionFiles).toHaveBeenCalledWith({
|
||||
needsFullReindex: false,
|
||||
targetSessionFiles: ["/tmp/targeted-fallback.jsonl"],
|
||||
progress: undefined,
|
||||
});
|
||||
expect(runUnsafeReindex).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the unsafe reindex path when enabled", async () => {
|
||||
const runSafeReindex = vi.fn(async () => {});
|
||||
const runUnsafeReindex = vi.fn(async () => {});
|
||||
|
||||
await runMemoryTargetedSessionSync({
|
||||
hasSessionSource: true,
|
||||
targetSessionFiles: new Set(["/tmp/targeted-fallback.jsonl"]),
|
||||
reason: "post-compaction",
|
||||
progress: undefined,
|
||||
useUnsafeReindex: true,
|
||||
sessionsDirtyFiles: new Set(),
|
||||
syncSessionFiles: async () => {
|
||||
throw new Error("embedding backend failed");
|
||||
},
|
||||
shouldFallbackOnError: () => true,
|
||||
activateFallbackProvider: async () => true,
|
||||
runSafeReindex,
|
||||
runUnsafeReindex,
|
||||
});
|
||||
|
||||
expect(runUnsafeReindex).toHaveBeenCalledWith({
|
||||
reason: "post-compaction",
|
||||
force: true,
|
||||
progress: undefined,
|
||||
});
|
||||
expect(runSafeReindex).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ handled: true, sessionsDirty: true });
|
||||
expect(sessionsDirtyFiles.has("/tmp/targeted-fallback.jsonl")).toBe(true);
|
||||
expect(sessionsDirtyFiles.has("/tmp/other-dirty.jsonl")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,12 +22,23 @@ export function clearMemorySyncedSessionFiles(params: {
|
||||
return params.sessionsDirtyFiles.size > 0;
|
||||
}
|
||||
|
||||
export function markMemoryTargetSessionFilesDirty(params: {
|
||||
sessionsDirtyFiles: Set<string>;
|
||||
targetSessionFiles?: Iterable<string> | null;
|
||||
}): boolean {
|
||||
if (params.targetSessionFiles) {
|
||||
for (const targetSessionFile of params.targetSessionFiles) {
|
||||
params.sessionsDirtyFiles.add(targetSessionFile);
|
||||
}
|
||||
}
|
||||
return params.sessionsDirtyFiles.size > 0;
|
||||
}
|
||||
|
||||
export async function runMemoryTargetedSessionSync(params: {
|
||||
hasSessionSource: boolean;
|
||||
targetSessionFiles: Set<string> | null;
|
||||
reason?: string;
|
||||
progress?: TargetedSyncProgress;
|
||||
useUnsafeReindex: boolean;
|
||||
sessionsDirtyFiles: Set<string>;
|
||||
syncSessionFiles: (params: {
|
||||
needsFullReindex: boolean;
|
||||
@@ -36,16 +47,6 @@ export async function runMemoryTargetedSessionSync(params: {
|
||||
}) => Promise<void>;
|
||||
shouldFallbackOnError: (err: unknown) => boolean;
|
||||
activateFallbackProvider: (reason: string) => Promise<boolean>;
|
||||
runSafeReindex: (params: {
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
progress?: TargetedSyncProgress;
|
||||
}) => Promise<void>;
|
||||
runUnsafeReindex: (params: {
|
||||
reason?: string;
|
||||
force?: boolean;
|
||||
progress?: TargetedSyncProgress;
|
||||
}) => Promise<void>;
|
||||
}): Promise<{ handled: boolean; sessionsDirty: boolean }> {
|
||||
if (!params.hasSessionSource || !params.targetSessionFiles) {
|
||||
return {
|
||||
@@ -74,19 +75,12 @@ export async function runMemoryTargetedSessionSync(params: {
|
||||
if (!activated) {
|
||||
throw err;
|
||||
}
|
||||
const reindexParams = {
|
||||
reason: params.reason,
|
||||
force: true,
|
||||
progress: params.progress,
|
||||
};
|
||||
if (params.useUnsafeReindex) {
|
||||
await params.runUnsafeReindex(reindexParams);
|
||||
} else {
|
||||
await params.runSafeReindex(reindexParams);
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
sessionsDirty: params.sessionsDirtyFiles.size > 0,
|
||||
sessionsDirty: markMemoryTargetSessionFilesDirty({
|
||||
sessionsDirtyFiles: params.sessionsDirtyFiles,
|
||||
targetSessionFiles: params.targetSessionFiles,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock("./embeddings.js", () => ({
|
||||
provider: null,
|
||||
providerUnavailableReason: "No embeddings provider available.",
|
||||
}),
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
resolveEmbeddingProviderFallbackModel: () => "fts-only",
|
||||
}));
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
resolveMemoryProviderState,
|
||||
type MemoryProviderLifecycleState,
|
||||
} from "./manager-provider-state.js";
|
||||
import type { MemoryIndexIdentityState } from "./manager-reindex-state.js";
|
||||
import { resolveMemorySearchPreflight } from "./manager-search-preflight.js";
|
||||
import { searchKeyword, searchVector } from "./manager-search.js";
|
||||
import {
|
||||
@@ -171,6 +172,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
protected override sessionsDirty = false;
|
||||
protected override sessionsDirtyFiles = new Set<string>();
|
||||
protected override sessionPendingFiles = new Set<string>();
|
||||
private indexIdentityDirty = false;
|
||||
protected override sessionDeltas = new Map<
|
||||
string,
|
||||
{ lastSize: number; pendingBytes: number; pendingMessages: number }
|
||||
@@ -183,6 +185,10 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
private readonlyRecoverySuccesses = 0;
|
||||
private readonlyRecoveryFailures = 0;
|
||||
private readonlyRecoveryLastError?: string;
|
||||
private indexIdentityState: MemoryIndexIdentityState = {
|
||||
status: "missing",
|
||||
reason: "index metadata is missing",
|
||||
};
|
||||
|
||||
private static async loadProviderResult(params: {
|
||||
cfg: OpenClawConfig;
|
||||
@@ -267,6 +273,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
if (meta?.vectorDims) {
|
||||
this.vector.dims = meta.vectorDims;
|
||||
}
|
||||
const initialIndexIdentity = this.resolveCurrentIndexIdentityState({
|
||||
meta,
|
||||
providerKeyKnown: Boolean(params.providerResult),
|
||||
});
|
||||
this.indexIdentityState = initialIndexIdentity;
|
||||
this.indexIdentityDirty =
|
||||
initialIndexIdentity.status === "mismatched" ||
|
||||
(initialIndexIdentity.status === "missing" && this.sources.has("memory"));
|
||||
const transient = params.purpose === "status" || params.purpose === "cli";
|
||||
if (!transient) {
|
||||
this.ensureWatcher();
|
||||
@@ -377,6 +391,23 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
}
|
||||
}
|
||||
|
||||
private refreshIndexIdentityDirty(params?: { providerKeyKnown?: boolean }) {
|
||||
const provider = this.providerInitialized
|
||||
? this.provider
|
||||
? { id: this.provider.id, model: this.provider.model }
|
||||
: null
|
||||
: undefined;
|
||||
const state = this.resolveCurrentIndexIdentityState({
|
||||
...(provider !== undefined ? { provider } : {}),
|
||||
providerKeyKnown: params?.providerKeyKnown,
|
||||
});
|
||||
this.indexIdentityState = state;
|
||||
this.indexIdentityDirty =
|
||||
state.status === "mismatched" ||
|
||||
(state.status === "missing" && (this.sources.has("memory") || this.hasIndexedChunks()));
|
||||
return state;
|
||||
}
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts?: {
|
||||
@@ -423,6 +454,27 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
if (preflight.shouldInitializeProvider) {
|
||||
await this.ensureProviderInitialized();
|
||||
}
|
||||
if (!this.provider && this.providerLifecycle.mode === "degraded") {
|
||||
const activatedFallback = await this.activateFallbackProvider(
|
||||
this.providerLifecycle.reason,
|
||||
).catch((fallbackErr: unknown) => {
|
||||
log.warn(
|
||||
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
if (activatedFallback) {
|
||||
this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
});
|
||||
}
|
||||
}
|
||||
const indexIdentity = this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
});
|
||||
if (indexIdentity.status !== "valid") {
|
||||
return [];
|
||||
}
|
||||
const minScore = opts?.minScore ?? this.settings.query.minScore;
|
||||
const maxResults = opts?.maxResults ?? this.settings.query.maxResults;
|
||||
const searchSources =
|
||||
@@ -443,20 +495,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
Math.max(1, Math.floor(maxResults * hybrid.candidateMultiplier)),
|
||||
);
|
||||
|
||||
if (!this.provider && this.providerLifecycle.mode === "degraded") {
|
||||
const activatedFallback = await this.activateFallbackProvider(
|
||||
this.providerLifecycle.reason,
|
||||
).catch((fallbackErr: unknown) => {
|
||||
log.warn(
|
||||
`memory search: failed to activate fallback provider: ${formatErrorMessage(fallbackErr)}`,
|
||||
);
|
||||
return false;
|
||||
});
|
||||
if (activatedFallback) {
|
||||
await this.runSafeReindex({ reason: "fallback", force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// FTS-only mode: no embedding provider available
|
||||
if (!this.provider) {
|
||||
if (!this.fts.enabled || !this.fts.available) {
|
||||
@@ -552,7 +590,13 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
})
|
||||
: false;
|
||||
if (activatedFallback) {
|
||||
await this.runSafeReindex({ reason: "fallback", force: true });
|
||||
if (
|
||||
this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
}).status !== "valid"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
keywordResults = await loadKeywordResults();
|
||||
queryVec = await this.embedQueryWithRetry(cleaned);
|
||||
} else if (!this.provider && this.fts.enabled && this.fts.available) {
|
||||
@@ -856,6 +900,9 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
}
|
||||
|
||||
status(): MemoryProviderStatus {
|
||||
this.refreshIndexIdentityDirty({
|
||||
providerKeyKnown: this.providerInitialized,
|
||||
});
|
||||
const sourceFilter = this.buildSourceFilter();
|
||||
const aggregateState = collectMemoryStatusAggregate({
|
||||
db: {
|
||||
@@ -884,7 +931,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
backend: "builtin",
|
||||
files: aggregateState.files,
|
||||
chunks: aggregateState.chunks,
|
||||
dirty: this.dirty || this.sessionsDirty,
|
||||
dirty: this.dirty || this.sessionsDirty || this.indexIdentityDirty,
|
||||
workspaceDir: this.workspaceDir,
|
||||
dbPath: this.settings.store.path,
|
||||
provider: providerInfo.provider,
|
||||
@@ -937,6 +984,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
searchMode: providerInfo.searchMode,
|
||||
providerState: this.providerLifecycle,
|
||||
providerUnavailableReason: this.providerUnavailableReason,
|
||||
indexIdentity: this.indexIdentityState,
|
||||
readonlyRecovery: {
|
||||
attempts: this.readonlyRecoveryAttempts,
|
||||
successes: this.readonlyRecoverySuccesses,
|
||||
|
||||
@@ -126,6 +126,7 @@ vi.mock("./sqlite-vec.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./embeddings.js", () => ({
|
||||
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
|
||||
createEmbeddingProvider: async () => ({
|
||||
requestedProvider: "openai",
|
||||
provider: {
|
||||
|
||||
@@ -117,15 +117,25 @@ export function createMemoryTool(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMemorySearchUnavailableResult(error: string | undefined) {
|
||||
export function buildMemorySearchUnavailableResult(
|
||||
error: string | undefined,
|
||||
overrides?: {
|
||||
warning?: string;
|
||||
action?: string;
|
||||
},
|
||||
) {
|
||||
const reason = (error ?? "memory search unavailable").trim() || "memory search unavailable";
|
||||
const isQuotaError = /insufficient_quota|quota|429/.test(normalizeLowercaseStringOrEmpty(reason));
|
||||
const warning = isQuotaError
|
||||
? "Memory search is unavailable because the embedding provider quota is exhausted."
|
||||
: "Memory search is unavailable due to an embedding/provider error.";
|
||||
const action = isQuotaError
|
||||
? "Top up or switch embedding provider, then retry memory_search."
|
||||
: "Check embedding provider configuration and retry memory_search.";
|
||||
const warning =
|
||||
overrides?.warning ??
|
||||
(isQuotaError
|
||||
? "Memory search is unavailable because the embedding provider quota is exhausted."
|
||||
: "Memory search is unavailable due to an embedding/provider error.");
|
||||
const action =
|
||||
overrides?.action ??
|
||||
(isQuotaError
|
||||
? "Top up or switch embedding provider, then retry memory_search."
|
||||
: "Check embedding provider configuration and retry memory_search.");
|
||||
return {
|
||||
results: [],
|
||||
disabled: true,
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
getMemorySearchManagerMockCalls,
|
||||
getMemorySearchManagerMockConfigs,
|
||||
getMemorySearchManagerMockParams,
|
||||
getMemorySyncMockCalls,
|
||||
resetMemoryToolMockState,
|
||||
setMemoryBackend,
|
||||
setMemoryCustomStatus,
|
||||
setMemorySearchImpl,
|
||||
setMemorySearchManagerImpl,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
@@ -256,6 +258,39 @@ describe("memory_search unavailable payloads", () => {
|
||||
expect(searchCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("returns unavailable metadata when the index identity is paused", async () => {
|
||||
let searchCalls = 0;
|
||||
setMemorySearchImpl(async () => {
|
||||
searchCalls += 1;
|
||||
return [];
|
||||
});
|
||||
const reason = "index was built for provider openai, expected ollama";
|
||||
setMemoryCustomStatus({
|
||||
indexIdentity: {
|
||||
status: "mismatched",
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
agents: { list: [{ id: "main", default: true }] },
|
||||
memory: { citations: "off" },
|
||||
},
|
||||
});
|
||||
const result = await tool.execute("paused-index", { query: "hidden thread codename" });
|
||||
|
||||
expectUnavailableMemorySearchDetails(result.details, {
|
||||
error: reason,
|
||||
warning:
|
||||
"Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.",
|
||||
action:
|
||||
"Tell the user to run: openclaw memory status --index or openclaw memory index --force.",
|
||||
});
|
||||
expect(searchCalls).toBe(1);
|
||||
expect(getMemorySyncMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns structured search debug metadata for qmd results", async () => {
|
||||
setMemoryBackend("qmd");
|
||||
setMemorySearchImpl(async (opts) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDeepDreamingConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-status";
|
||||
import { asRecord } from "./dreaming-shared.js";
|
||||
import { filterMemorySearchHitsBySessionVisibility } from "./session-search-visibility.js";
|
||||
import { recordShortTermRecalls } from "./short-term-promotion.js";
|
||||
import {
|
||||
@@ -109,6 +110,28 @@ async function runMemorySearchToolWithDeadline<T>(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const PAUSED_MEMORY_INDEX_WARNING =
|
||||
"Tell the user: memory search is paused because the memory index was built with a different embedding provider/model/settings.";
|
||||
const PAUSED_MEMORY_INDEX_ACTION =
|
||||
"Tell the user to run: openclaw memory status --index or openclaw memory index --force.";
|
||||
|
||||
function resolvePausedMemoryIndexIdentityReason(status: { custom?: unknown }): string | undefined {
|
||||
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
|
||||
if (indexIdentity?.status !== "mismatched" && indexIdentity?.status !== "missing") {
|
||||
return undefined;
|
||||
}
|
||||
return typeof indexIdentity.reason === "string" && indexIdentity.reason.trim()
|
||||
? indexIdentity.reason.trim()
|
||||
: "memory index identity is missing or mismatched";
|
||||
}
|
||||
|
||||
function buildPausedMemoryIndexUnavailableResult(reason: string) {
|
||||
return buildMemorySearchUnavailableResult(reason, {
|
||||
warning: PAUSED_MEMORY_INDEX_WARNING,
|
||||
action: PAUSED_MEMORY_INDEX_ACTION,
|
||||
});
|
||||
}
|
||||
|
||||
function sortMemorySearchToolResults<T extends { score: number; path: string }>(results: T[]): T[] {
|
||||
return results.toSorted((left, right) => {
|
||||
if (left.score !== right.score) {
|
||||
@@ -316,7 +339,7 @@ export function createMemorySearchTool(options: {
|
||||
label: "Memory Search",
|
||||
name: "memory_search",
|
||||
description:
|
||||
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable and should be surfaced to the user.",
|
||||
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable; you must tell the user and include the warning/action guidance.",
|
||||
parameters: MemorySearchSchema,
|
||||
execute:
|
||||
({ cfg, agentId }) =>
|
||||
@@ -400,6 +423,7 @@ export function createMemorySearchTool(options: {
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
let searchMode: string | undefined;
|
||||
let pausedIndexIdentityReason: string | undefined;
|
||||
let searchDebug:
|
||||
| {
|
||||
backend: string;
|
||||
@@ -447,9 +471,21 @@ export function createMemorySearchTool(options: {
|
||||
activeMemory = refreshed;
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
}
|
||||
const statusBeforeRetry = activeMemory.manager.status();
|
||||
pausedIndexIdentityReason =
|
||||
resolvePausedMemoryIndexIdentityReason(statusBeforeRetry);
|
||||
if (pausedIndexIdentityReason) {
|
||||
return;
|
||||
}
|
||||
if (rawResults.length === 0 && activeMemory.manager.sync) {
|
||||
await activeMemory.manager.sync({ reason: "search", force: true });
|
||||
rawResults = await activeMemory.manager.search(query, searchOptions);
|
||||
pausedIndexIdentityReason = resolvePausedMemoryIndexIdentityReason(
|
||||
activeMemory.manager.status(),
|
||||
);
|
||||
if (pausedIndexIdentityReason) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
rawResults = await filterMemorySearchHitsBySessionVisibility({
|
||||
cfg,
|
||||
@@ -500,6 +536,11 @@ export function createMemorySearchTool(options: {
|
||||
hits: rawResults.length,
|
||||
};
|
||||
});
|
||||
if (pausedIndexIdentityReason) {
|
||||
return jsonResult(
|
||||
buildPausedMemoryIndexUnavailableResult(pausedIndexIdentityReason),
|
||||
);
|
||||
}
|
||||
}
|
||||
const supplementResults = shouldQuerySupplements
|
||||
? await runUnavailablePhase(
|
||||
|
||||
@@ -3,7 +3,9 @@ import {
|
||||
canonicalizeCodexResponsesBaseUrl,
|
||||
isOpenAIApiBaseUrl,
|
||||
isOpenAICodexBaseUrl,
|
||||
OPENAI_API_BASE_URL,
|
||||
OPENAI_CODEX_RESPONSES_BASE_URL,
|
||||
resolveOpenAIDefaultBaseUrl,
|
||||
} from "./base-url.js";
|
||||
|
||||
describe("openai base URL helpers", () => {
|
||||
@@ -57,4 +59,12 @@ describe("openai base URL helpers", () => {
|
||||
);
|
||||
expect(canonicalizeCodexResponsesBaseUrl(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves default API base URL from OPENAI_BASE_URL", () => {
|
||||
expect(resolveOpenAIDefaultBaseUrl({})).toBe(OPENAI_API_BASE_URL);
|
||||
expect(resolveOpenAIDefaultBaseUrl({ OPENAI_BASE_URL: " " })).toBe(OPENAI_API_BASE_URL);
|
||||
expect(resolveOpenAIDefaultBaseUrl({ OPENAI_BASE_URL: " https://proxy.example/v1 " })).toBe(
|
||||
"https://proxy.example/v1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const OPENAI_CODEX_RESPONSES_BASE_URL = "https://chatgpt.com/backend-api/codex";
|
||||
export const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
|
||||
|
||||
export function resolveOpenAIDefaultBaseUrl(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string {
|
||||
return normalizeOptionalString(env.OPENAI_BASE_URL) ?? OPENAI_API_BASE_URL;
|
||||
}
|
||||
|
||||
export function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
|
||||
const trimmed = normalizeOptionalString(baseUrl);
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { OPENAI_ACCOUNT_WIZARD_GROUP, OPENAI_API_KEY_LABEL } from "./auth-choice-copy.js";
|
||||
import { isOpenAIApiBaseUrl, isOpenAICodexBaseUrl } from "./base-url.js";
|
||||
import {
|
||||
isOpenAIApiBaseUrl,
|
||||
isOpenAICodexBaseUrl,
|
||||
resolveOpenAIDefaultBaseUrl,
|
||||
} from "./base-url.js";
|
||||
import { applyOpenAIConfig, OPENAI_DEFAULT_MODEL } from "./default-models.js";
|
||||
import {
|
||||
buildOpenAIChatGPTAuthMethods,
|
||||
@@ -203,7 +207,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: OPENAI_CHAT_LATEST_COST,
|
||||
@@ -215,7 +219,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
mediaInput: OPENAI_GPT_55_MEDIA_INPUT,
|
||||
@@ -229,7 +233,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENAI_GPT_55_PRO_COST,
|
||||
@@ -241,7 +245,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENAI_GPT_54_COST,
|
||||
@@ -253,7 +257,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENAI_GPT_54_PRO_COST,
|
||||
@@ -265,7 +269,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENAI_GPT_54_MINI_COST,
|
||||
@@ -277,7 +281,7 @@ function resolveOpenAIGptForwardCompatModel(ctx: ProviderResolveDynamicModelCont
|
||||
patch = {
|
||||
api: "openai-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
baseUrl: resolveOpenAIDefaultBaseUrl(),
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENAI_GPT_54_NANO_COST,
|
||||
|
||||
@@ -42,6 +42,10 @@ describe("QQBot token manager", () => {
|
||||
url: "https://bots.qq.com/app/getAppAccessToken",
|
||||
auditContext: "qqbot-token",
|
||||
capture: false,
|
||||
policy: {
|
||||
hostnameAllowlist: ["bots.qq.com"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -54,6 +58,25 @@ describe("QQBot token manager", () => {
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes the RFC2544 SSRF allowance to the token fetch (regression for #88984)", async () => {
|
||||
mockGuardedTokenResponse('{"access_token":"token-1","expires_in":7200}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
await expect(new TokenManager().getAccessToken("app-id", "secret")).resolves.toBe("token-1");
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://bots.qq.com/app/getAppAccessToken",
|
||||
auditContext: "qqbot-token",
|
||||
policy: {
|
||||
hostnameAllowlist: ["bots.qq.com"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not cache access tokens forever when expires_in is unsafe", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
|
||||
@@ -12,13 +12,31 @@ import {
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
resolveTimestampMsToIsoString,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { EngineLogger } from "../types.js";
|
||||
import { formatErrorMessage } from "../utils/format.js";
|
||||
|
||||
const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
|
||||
const DEFAULT_TOKEN_EXPIRES_IN_SECONDS = 7200;
|
||||
|
||||
/**
|
||||
* Host-scoped SSRF policy for the QQ Bot token endpoint.
|
||||
*
|
||||
* `TOKEN_URL` is a hard-coded `https://bots.qq.com/...` constant, so this
|
||||
* relaxation only ever applies to that single host. Fake-IP proxy stacks
|
||||
* (sing-box, Clash, Surge, WSL2 DNS, etc.) routinely map `bots.qq.com` into
|
||||
* the RFC 2544 benchmark range `198.18.0.0/15`, which the default SSRF
|
||||
* guard blocks. We mirror the existing media-path pattern
|
||||
* (`QQBOT_MEDIA_SSRF_POLICY` in `../utils/file-utils.ts`) so the relaxation
|
||||
* stays narrowly host-scoped instead of weakening the global default.
|
||||
*
|
||||
* See https://github.com/openclaw/openclaw/issues/88984.
|
||||
*/
|
||||
const QQBOT_TOKEN_SSRF_POLICY: SsrFPolicy = {
|
||||
hostnameAllowlist: ["bots.qq.com"],
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
};
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
@@ -234,6 +252,7 @@ export class TokenManager {
|
||||
url: TOKEN_URL,
|
||||
auditContext: "qqbot-token",
|
||||
capture: false,
|
||||
policy: QQBOT_TOKEN_SSRF_POLICY,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
74
extensions/qqbot/src/engine/utils/data-paths.test.ts
Normal file
74
extensions/qqbot/src/engine/utils/data-paths.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCredentialBackupFile, getLegacyCredentialBackupFile } from "./data-paths.js";
|
||||
|
||||
const createdStateDirs: string[] = [];
|
||||
|
||||
function createTempDir(prefix: string): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
createdStateDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("qqbot credential backup paths", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
for (const stateDir of createdStateDirs.splice(0)) {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes credential backups to the active OPENCLAW_STATE_DIR", () => {
|
||||
const stateDir = createTempDir("qqbot-state-");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
||||
|
||||
expect(getCredentialBackupFile("default")).toBe(
|
||||
path.join(stateDir, "qqbot", "data", "credential-backup-default.json"),
|
||||
);
|
||||
expect(getLegacyCredentialBackupFile()).toBe(
|
||||
path.join(stateDir, "qqbot", "data", "credential-backup.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps same account IDs isolated across different state directories", () => {
|
||||
const stateDirA = createTempDir("qqbot-state-a-");
|
||||
const stateDirB = createTempDir("qqbot-state-b-");
|
||||
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirA);
|
||||
const gatewayAPath = getCredentialBackupFile("default");
|
||||
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", stateDirB);
|
||||
const gatewayBPath = getCredentialBackupFile("default");
|
||||
|
||||
expect(gatewayAPath).toBe(
|
||||
path.join(stateDirA, "qqbot", "data", "credential-backup-default.json"),
|
||||
);
|
||||
expect(gatewayBPath).toBe(
|
||||
path.join(stateDirB, "qqbot", "data", "credential-backup-default.json"),
|
||||
);
|
||||
expect(gatewayBPath).not.toBe(gatewayAPath);
|
||||
});
|
||||
|
||||
it("uses OPENCLAW_HOME for default credential backup state", () => {
|
||||
const homeDir = createTempDir("qqbot-openclaw-home-");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "");
|
||||
vi.stubEnv("OPENCLAW_HOME", homeDir);
|
||||
|
||||
expect(getCredentialBackupFile("default")).toBe(
|
||||
path.join(homeDir, ".openclaw", "qqbot", "data", "credential-backup-default.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("expands tilde state-dir overrides through the canonical state resolver", () => {
|
||||
const homeDir = createTempDir("qqbot-home-");
|
||||
vi.stubEnv("HOME", homeDir);
|
||||
vi.stubEnv("OPENCLAW_HOME", "");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", "~/gateway-a");
|
||||
|
||||
expect(getCredentialBackupFile("default")).toBe(
|
||||
path.join(homeDir, "gateway-a", "qqbot", "data", "credential-backup-default.json"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import { getQQBotDataPath } from "./platform.js";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
|
||||
/**
|
||||
* Normalise an identifier so it is safe to embed in a filename.
|
||||
@@ -21,6 +21,10 @@ function safeName(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
}
|
||||
|
||||
function getCredentialBackupRoot(): string {
|
||||
return path.join(resolveStateDir(process.env), "qqbot", "data");
|
||||
}
|
||||
|
||||
// ---- credential backup ----
|
||||
|
||||
/**
|
||||
@@ -29,10 +33,10 @@ function safeName(id: string): string {
|
||||
* missing from the live config.
|
||||
*/
|
||||
export function getCredentialBackupFile(accountId: string): string {
|
||||
return path.join(getQQBotDataPath("data"), `credential-backup-${safeName(accountId)}.json`);
|
||||
return path.join(getCredentialBackupRoot(), `credential-backup-${safeName(accountId)}.json`);
|
||||
}
|
||||
|
||||
/** Legacy single-file credential backup (pre-multi-account-isolation). */
|
||||
export function getLegacyCredentialBackupFile(): string {
|
||||
return path.join(getQQBotDataPath("data"), "credential-backup.json");
|
||||
return path.join(getCredentialBackupRoot(), "credential-backup.json");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user