Merge branch 'main' into fix/local-gateway-probe-device-identity

This commit is contained in:
JEFFREY WONG ZONG HAN 2026-03-15 14:09:15 +08:00 committed by GitHub
commit d5207d1fc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
164 changed files with 59936 additions and 1029 deletions

45
.github/CODEOWNERS vendored
View File

@ -1,6 +1,51 @@
# Protect the ownership rules themselves.
/.github/CODEOWNERS @steipete
# WARNING: GitHub CODEOWNERS uses last-match-wins semantics.
# If you add overlapping rules below the secops block, include @openclaw/secops
# on those entries too or you can silently remove required secops review.
# Security-sensitive code, config, and docs require secops review.
/SECURITY.md @openclaw/secops
/.github/dependabot.yml @openclaw/secops
/.github/codeql/ @openclaw/secops
/.github/workflows/codeql.yml @openclaw/secops
/src/security/ @openclaw/secops
/src/secrets/ @openclaw/secops
/src/config/*secret*.ts @openclaw/secops
/src/config/**/*secret*.ts @openclaw/secops
/src/gateway/*auth*.ts @openclaw/secops
/src/gateway/**/*auth*.ts @openclaw/secops
/src/gateway/*secret*.ts @openclaw/secops
/src/gateway/**/*secret*.ts @openclaw/secops
/src/gateway/security-path*.ts @openclaw/secops
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/secops
/src/gateway/protocol/**/*secret*.ts @openclaw/secops
/src/gateway/server-methods/secrets*.ts @openclaw/secops
/src/agents/*auth*.ts @openclaw/secops
/src/agents/**/*auth*.ts @openclaw/secops
/src/agents/auth-profiles*.ts @openclaw/secops
/src/agents/auth-health*.ts @openclaw/secops
/src/agents/auth-profiles/ @openclaw/secops
/src/agents/sandbox.ts @openclaw/secops
/src/agents/sandbox-*.ts @openclaw/secops
/src/agents/sandbox/ @openclaw/secops
/src/infra/secret-file*.ts @openclaw/secops
/src/cron/stagger.ts @openclaw/secops
/src/cron/service/jobs.ts @openclaw/secops
/docs/security/ @openclaw/secops
/docs/gateway/authentication.md @openclaw/secops
/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @openclaw/secops
/docs/gateway/sandboxing.md @openclaw/secops
/docs/gateway/secrets-plan-contract.md @openclaw/secops
/docs/gateway/secrets.md @openclaw/secops
/docs/gateway/security/ @openclaw/secops
/docs/cli/approvals.md @openclaw/secops
/docs/cli/sandbox.md @openclaw/secops
/docs/cli/security.md @openclaw/secops
/docs/cli/secrets.md @openclaw/secops
/docs/reference/secretref-credential-surface.md @openclaw/secops
/docs/reference/secretref-user-supplied-credentials-matrix.json @openclaw/secops
# Release workflow and its supporting release-path checks.
/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers
/docs/reference/RELEASING.md @openclaw/openclaw-release-managers

View File

@ -4,6 +4,7 @@ on:
pull_request:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@ -14,6 +15,7 @@ env:
jobs:
no-tabs:
if: github.event_name != 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@ -45,6 +47,7 @@ jobs:
PY
actionlint:
if: github.event_name != 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
@ -68,3 +71,19 @@ jobs:
- name: Disallow direct inputs interpolation in composite run blocks
run: python3 scripts/check-composite-action-input-interpolation.py
config-docs-drift:
if: github.event_name == 'workflow_dispatch'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Check config docs drift statefile
run: pnpm config:docs:check

View File

@ -12314,14 +12314,14 @@
"filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false,
"line_number": 653
"line_number": 657
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false,
"line_number": 686
"line_number": 690
}
],
"src/config/schema.irc.ts": [
@ -12360,14 +12360,14 @@
"filename": "src/config/schema.labels.ts",
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
"is_verified": false,
"line_number": 217
"line_number": 219
},
{
"type": "Secret Keyword",
"filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false,
"line_number": 326
"line_number": 328
}
],
"src/config/slack-http-config.test.ts": [

View File

@ -9,6 +9,7 @@
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Auto-close labels (issues and PRs)

View File

@ -6,20 +6,31 @@ Docs: https://docs.openclaw.ai
### Changes
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
### Fixes
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
### Fixes
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
## 2026.3.13
@ -95,6 +106,8 @@ Docs: https://docs.openclaw.ai
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
## 2026.3.12

View File

@ -96,6 +96,7 @@ Welcome to the lobster tank! 🦞
- Reply to or resolve bot review conversations you addressed before asking for review again
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
- Use American English spelling and grammar in code, comments, docs, and UI strings
- Do not edit files covered by `CODEOWNERS` security ownership unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted review surfaces, not opportunistic cleanup targets.
## Review Conversations Are Author-Owned

View File

@ -134,7 +134,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git openssl
procps hostname curl git lsof openssl
RUN chown node:node /app

View File

@ -51,6 +51,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.mobileCardSurface
private enum class ConnectInputMode {
SetupCode,
@ -91,20 +92,28 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") },
containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
text = {
Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout,
color = mobileText,
)
},
confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
TextButton(
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
) {
Text("Trust and continue")
}
},
dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
TextButton(
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
) {
Text("Cancel")
}
},
@ -144,7 +153,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White,
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Column {
@ -205,7 +214,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = Color.White,
containerColor = mobileCardSurface,
contentColor = mobileDanger,
),
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
@ -298,7 +307,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color.White,
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorder),
) {
Column(
@ -480,7 +489,7 @@ private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
containerColor = if (active) mobileAccent else mobileSurface,
contentColor = if (active) Color.White else mobileText,
),
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
}
@ -509,10 +518,10 @@ private fun CommandBlock(command: String) {
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
border = BorderStroke(1.dp, mobileCodeBorder),
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
Box(modifier = Modifier.width(3.dp).height(42.dp).background(mobileCodeAccent))
Text(
text = command,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),

View File

@ -1,5 +1,7 @@
package ai.openclaw.app.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
@ -9,32 +11,147 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import ai.openclaw.app.R
internal val mobileBackgroundGradient =
Brush.verticalGradient(
listOf(
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
),
// ---------------------------------------------------------------------------
// MobileColors semantic color tokens with light + dark variants
// ---------------------------------------------------------------------------
internal data class MobileColors(
val surface: Color,
val surfaceStrong: Color,
val cardSurface: Color,
val border: Color,
val borderStrong: Color,
val text: Color,
val textSecondary: Color,
val textTertiary: Color,
val accent: Color,
val accentSoft: Color,
val accentBorderStrong: Color,
val success: Color,
val successSoft: Color,
val warning: Color,
val warningSoft: Color,
val danger: Color,
val dangerSoft: Color,
val codeBg: Color,
val codeText: Color,
val codeBorder: Color,
val codeAccent: Color,
val chipBorderConnected: Color,
val chipBorderConnecting: Color,
val chipBorderWarning: Color,
val chipBorderError: Color,
)
internal fun lightMobileColors() =
MobileColors(
surface = Color(0xFFF6F7FA),
surfaceStrong = Color(0xFFECEEF3),
cardSurface = Color(0xFFFFFFFF),
border = Color(0xFFE5E7EC),
borderStrong = Color(0xFFD6DAE2),
text = Color(0xFF17181C),
textSecondary = Color(0xFF5D6472),
textTertiary = Color(0xFF99A0AE),
accent = Color(0xFF1D5DD8),
accentSoft = Color(0xFFECF3FF),
accentBorderStrong = Color(0xFF184DAF),
success = Color(0xFF2F8C5A),
successSoft = Color(0xFFEEF9F3),
warning = Color(0xFFC8841A),
warningSoft = Color(0xFFFFF8EC),
danger = Color(0xFFD04B4B),
dangerSoft = Color(0xFFFFF2F2),
codeBg = Color(0xFF15171B),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFFCFEBD8),
chipBorderConnecting = Color(0xFFD5E2FA),
chipBorderWarning = Color(0xFFEED8B8),
chipBorderError = Color(0xFFF3C8C8),
)
internal val mobileSurface = Color(0xFFF6F7FA)
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
internal val mobileBorder = Color(0xFFE5E7EC)
internal val mobileBorderStrong = Color(0xFFD6DAE2)
internal val mobileText = Color(0xFF17181C)
internal val mobileTextSecondary = Color(0xFF5D6472)
internal val mobileTextTertiary = Color(0xFF99A0AE)
internal val mobileAccent = Color(0xFF1D5DD8)
internal val mobileAccentSoft = Color(0xFFECF3FF)
internal val mobileSuccess = Color(0xFF2F8C5A)
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
internal val mobileWarning = Color(0xFFC8841A)
internal val mobileWarningSoft = Color(0xFFFFF8EC)
internal val mobileDanger = Color(0xFFD04B4B)
internal val mobileDangerSoft = Color(0xFFFFF2F2)
internal val mobileCodeBg = Color(0xFF15171B)
internal val mobileCodeText = Color(0xFFE8EAEE)
internal fun darkMobileColors() =
MobileColors(
surface = Color(0xFF1A1C20),
surfaceStrong = Color(0xFF24262B),
cardSurface = Color(0xFF1E2024),
border = Color(0xFF2E3038),
borderStrong = Color(0xFF3A3D46),
text = Color(0xFFE4E5EA),
textSecondary = Color(0xFFA0A6B4),
textTertiary = Color(0xFF6B7280),
accent = Color(0xFF6EA8FF),
accentSoft = Color(0xFF1A2A44),
accentBorderStrong = Color(0xFF5B93E8),
success = Color(0xFF5FBB85),
successSoft = Color(0xFF152E22),
warning = Color(0xFFE8A844),
warningSoft = Color(0xFF2E2212),
danger = Color(0xFFE87070),
dangerSoft = Color(0xFF2E1616),
codeBg = Color(0xFF111317),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFF1E4A30),
chipBorderConnecting = Color(0xFF1E3358),
chipBorderWarning = Color(0xFF3E3018),
chipBorderError = Color(0xFF3E1E1E),
)
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
internal object MobileColorsAccessor {
val current: MobileColors
@Composable get() = LocalMobileColors.current
}
// ---------------------------------------------------------------------------
// Backward-compatible top-level accessors (composable getters)
// ---------------------------------------------------------------------------
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
// without converting every file at once. Each resolves to the themed value.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
// Background gradient light fades white→gray, dark fades near-black→dark-gray
internal val mobileBackgroundGradient: Brush
@Composable get() {
val colors = LocalMobileColors.current
return Brush.verticalGradient(
listOf(
colors.surface,
colors.surfaceStrong,
colors.surfaceStrong,
),
)
}
// ---------------------------------------------------------------------------
// Typography tokens (theme-independent)
// ---------------------------------------------------------------------------
internal val mobileFontFamily =
FontFamily(
@ -44,6 +161,15 @@ internal val mobileFontFamily =
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
internal val mobileDisplay =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
)
internal val mobileTitle1 =
TextStyle(
fontFamily = mobileFontFamily,

View File

@ -81,7 +81,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
@ -94,7 +93,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.node.DeviceNotificationListenerService
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
@ -129,95 +127,80 @@ private enum class SpecialAccessToggle {
NotificationListener,
}
private val onboardingBackgroundGradient =
listOf(
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
)
private val onboardingSurface = Color(0xFFF6F7FA)
private val onboardingBorder = Color(0xFFE5E7EC)
private val onboardingBorderStrong = Color(0xFFD6DAE2)
private val onboardingText = Color(0xFF17181C)
private val onboardingTextSecondary = Color(0xFF4D5563)
private val onboardingTextTertiary = Color(0xFF8A92A2)
private val onboardingAccent = Color(0xFF1D5DD8)
private val onboardingAccentSoft = Color(0xFFECF3FF)
private val onboardingSuccess = Color(0xFF2F8C5A)
private val onboardingWarning = Color(0xFFC8841A)
private val onboardingCommandBg = Color(0xFF15171B)
private val onboardingCommandBorder = Color(0xFF2B2E35)
private val onboardingCommandAccent = Color(0xFF3FC97A)
private val onboardingCommandText = Color(0xFFE8EAEE)
private val onboardingBackgroundGradient: Brush
@Composable get() = mobileBackgroundGradient
private val onboardingFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
private val onboardingSurface: Color
@Composable get() = mobileCardSurface
private val onboardingDisplayStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
)
private val onboardingBorder: Color
@Composable get() = mobileBorder
private val onboardingTitle1Style =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.5).sp,
)
private val onboardingBorderStrong: Color
@Composable get() = mobileBorderStrong
private val onboardingHeadlineStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp,
)
private val onboardingText: Color
@Composable get() = mobileText
private val onboardingBodyStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)
private val onboardingTextSecondary: Color
@Composable get() = mobileTextSecondary
private val onboardingCalloutStyle =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
)
private val onboardingTextTertiary: Color
@Composable get() = mobileTextTertiary
private val onboardingCaption1Style =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
)
private val onboardingAccent: Color
@Composable get() = mobileAccent
private val onboardingCaption2Style =
TextStyle(
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.4.sp,
)
private val onboardingAccentSoft: Color
@Composable get() = mobileAccentSoft
private val onboardingAccentBorderStrong: Color
@Composable get() = mobileAccentBorderStrong
private val onboardingSuccess: Color
@Composable get() = mobileSuccess
private val onboardingSuccessSoft: Color
@Composable get() = mobileSuccessSoft
private val onboardingWarning: Color
@Composable get() = mobileWarning
private val onboardingWarningSoft: Color
@Composable get() = mobileWarningSoft
private val onboardingCommandBg: Color
@Composable get() = mobileCodeBg
private val onboardingCommandBorder: Color
@Composable get() = mobileCodeBorder
private val onboardingCommandAccent: Color
@Composable get() = mobileCodeAccent
private val onboardingCommandText: Color
@Composable get() = mobileCodeText
private val onboardingDisplayStyle: TextStyle
get() = mobileDisplay
private val onboardingTitle1Style: TextStyle
get() = mobileTitle1
private val onboardingHeadlineStyle: TextStyle
get() = mobileHeadline
private val onboardingBodyStyle: TextStyle
get() = mobileBody
private val onboardingCalloutStyle: TextStyle
get() = mobileCallout
private val onboardingCaption1Style: TextStyle
get() = mobileCaption1
private val onboardingCaption2Style: TextStyle
get() = mobileCaption2
@Composable
fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
@ -472,19 +455,28 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val prompt = pendingTrust!!
AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") },
containerColor = onboardingSurface,
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
text = {
Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = onboardingCalloutStyle,
color = onboardingText,
)
},
confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
TextButton(
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
) {
Text("Trust and continue")
}
},
dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
TextButton(
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
) {
Text("Cancel")
}
},
@ -495,7 +487,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
modifier =
modifier
.fillMaxSize()
.background(Brush.verticalGradient(onboardingBackgroundGradient)),
.background(onboardingBackgroundGradient),
) {
Column(
modifier =
@ -755,13 +747,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onClick = { step = OnboardingStep.Gateway },
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
colors = onboardingPrimaryButtonColors(),
) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@ -807,13 +793,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
colors = onboardingPrimaryButtonColors(),
) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@ -827,13 +807,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
colors = onboardingPrimaryButtonColors(),
) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@ -844,13 +818,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onClick = { viewModel.setOnboardingCompleted(true) },
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
colors = onboardingPrimaryButtonColors(),
) {
Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@ -883,13 +851,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
},
modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
colors = onboardingPrimaryButtonColors(),
) {
Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@ -901,6 +863,36 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}
}
@Composable
private fun onboardingPrimaryButtonColors() =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
@Composable
private fun onboardingTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
)
@Composable
private fun onboardingSwitchColors() =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
)
@Composable
private fun StepRail(current: OnboardingStep) {
val steps = OnboardingStep.entries
@ -1005,11 +997,7 @@ private fun GatewayStep(
onClick = onScanQrClick,
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
),
colors = onboardingPrimaryButtonColors(),
) {
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
}
@ -1059,15 +1047,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
onboardingTextFieldColors(),
)
if (!resolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = resolvedEndpoint)
@ -1097,15 +1077,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
onboardingTextFieldColors(),
)
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
@ -1119,15 +1091,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
onboardingTextFieldColors(),
)
Row(
@ -1143,12 +1107,7 @@ private fun GatewayStep(
checked = manualTls,
onCheckedChange = onManualTlsChange,
colors =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
onboardingSwitchColors(),
)
}
@ -1163,15 +1122,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
onboardingTextFieldColors(),
)
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
@ -1185,15 +1136,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp),
colors =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
onboardingTextFieldColors(),
)
if (!manualResolvedEndpoint.isNullOrBlank()) {
@ -1261,7 +1204,7 @@ private fun GatewayModeChip(
containerColor = if (active) onboardingAccent else onboardingSurface,
contentColor = if (active) Color.White else onboardingText,
),
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong),
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) onboardingAccentBorderStrong else onboardingBorderStrong),
) {
Text(
text = label,
@ -1524,13 +1467,7 @@ private fun PermissionToggleRow(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
colors = onboardingSwitchColors(),
)
}
}
@ -1605,7 +1542,7 @@ private fun FinalStep(
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color(0xFFEEF9F3),
color = onboardingSuccessSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
) {
Row(
@ -1641,7 +1578,7 @@ private fun FinalStep(
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = Color(0xFFFFF8EC),
color = onboardingWarningSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
) {
Column(

View File

@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -13,8 +14,11 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
MaterialTheme(colorScheme = colorScheme, content = content)
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
}
@Composable

View File

@ -159,28 +159,28 @@ private fun TopStatusBar(
mobileSuccessSoft,
mobileSuccess,
mobileSuccess,
Color(0xFFCFEBD8),
LocalMobileColors.current.chipBorderConnected,
)
StatusVisual.Connecting ->
listOf(
mobileAccentSoft,
mobileAccent,
mobileAccent,
Color(0xFFD5E2FA),
LocalMobileColors.current.chipBorderConnecting,
)
StatusVisual.Warning ->
listOf(
mobileWarningSoft,
mobileWarning,
mobileWarning,
Color(0xFFEED8B8),
LocalMobileColors.current.chipBorderWarning,
)
StatusVisual.Error ->
listOf(
mobileDangerSoft,
mobileDanger,
mobileDanger,
Color(0xFFF3C8C8),
LocalMobileColors.current.chipBorderError,
)
StatusVisual.Offline ->
listOf(
@ -249,7 +249,7 @@ private fun BottomTabBar(
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.97f),
color = mobileCardSurface.copy(alpha = 0.97f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 6.dp,
@ -270,7 +270,7 @@ private fun BottomTabBar(
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
shape = RoundedCornerShape(16.dp),
color = if (active) mobileAccentSoft else Color.Transparent,
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
border = if (active) BorderStroke(1.dp, LocalMobileColors.current.chipBorderConnecting) else null,
shadowElevation = 0.dp,
) {
Column(

View File

@ -736,11 +736,12 @@ private fun settingsTextFieldColors() =
cursorColor = mobileAccent,
)
@Composable
private fun Modifier.settingsRowModifier() =
this
.fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp))
.background(mobileCardSurface, RoundedCornerShape(14.dp))
@Composable
private fun settingsPrimaryButtonColors() =

View File

@ -363,7 +363,7 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
Surface(
modifier = Modifier.fillMaxWidth(0.90f),
shape = RoundedCornerShape(12.dp),
color = if (isUser) mobileAccentSoft else Color.White,
color = if (isUser) mobileAccentSoft else mobileCardSurface,
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
) {
Column(
@ -391,7 +391,7 @@ private fun VoiceThinkingBubble() {
Surface(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(12.dp),
color = Color.White,
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(

View File

@ -46,11 +46,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileAccentSoft
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileSurface
import ai.openclaw.app.ui.mobileText
@ -110,7 +112,7 @@ fun ChatComposer(
Surface(
onClick = { showThinkingMenu = true },
shape = RoundedCornerShape(14.dp),
color = Color.White,
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Row(
@ -126,7 +128,15 @@ fun ChatComposer(
}
}
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
DropdownMenu(
expanded = showThinkingMenu,
onDismissRequest = { showThinkingMenu = false },
shape = RoundedCornerShape(16.dp),
containerColor = mobileCardSurface,
tonalElevation = 0.dp,
shadowElevation = 8.dp,
border = BorderStroke(1.dp, mobileBorder),
) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
@ -177,7 +187,7 @@ fun ChatComposer(
disabledContainerColor = mobileBorderStrong,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
border = BorderStroke(1.dp, if (canSend) mobileAccentBorderStrong else mobileBorderStrong),
) {
if (sendBusy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
@ -211,9 +221,9 @@ private fun SecondaryActionButton(
shape = RoundedCornerShape(14.dp),
colors =
ButtonDefaults.buttonColors(
containerColor = Color.White,
containerColor = mobileCardSurface,
contentColor = mobileTextSecondary,
disabledContainerColor = Color.White,
disabledContainerColor = mobileCardSurface,
disabledContentColor = mobileTextTertiary,
),
border = BorderStroke(1.dp, mobileBorderStrong),
@ -303,7 +313,7 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
Surface(
onClick = onRemove,
shape = RoundedCornerShape(999.dp),
color = Color.White,
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorderStrong),
) {
Text(

View File

@ -94,7 +94,7 @@ private val markdownParser: Parser by lazy {
@Composable
fun ChatMarkdown(text: String, textColor: Color) {
val document = remember(text) { markdownParser.parse(text) as Document }
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RenderMarkdownBlocks(
@ -124,7 +124,7 @@ private fun RenderMarkdownBlocks(
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
Text(
text = headingText,
style = headingStyle(current.level),
style = headingStyle(current.level, inlineStyles.baseCallout),
color = textColor,
)
}
@ -231,7 +231,7 @@ private fun RenderParagraph(
Text(
text = annotated,
style = mobileCallout,
style = inlineStyles.baseCallout,
color = textColor,
)
}
@ -315,7 +315,7 @@ private fun RenderListItem(
) {
Text(
text = marker,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
style = inlineStyles.baseCallout.copy(fontWeight = FontWeight.SemiBold),
color = textColor,
modifier = Modifier.width(24.dp),
)
@ -360,7 +360,7 @@ private fun RenderTableBlock(
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
Text(
text = cell,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
color = textColor,
modifier = Modifier
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
@ -417,6 +417,7 @@ private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): Annot
node = start,
inlineCodeBg = inlineStyles.inlineCodeBg,
inlineCodeColor = inlineStyles.inlineCodeColor,
linkColor = inlineStyles.linkColor,
)
}
}
@ -425,6 +426,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
node: Node?,
inlineCodeBg: Color,
inlineCodeColor: Color,
linkColor: Color,
) {
var current = node
while (current != null) {
@ -445,27 +447,27 @@ private fun AnnotatedString.Builder.appendInlineNode(
}
is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
}
}
is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
}
}
is Strikethrough -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
}
}
is Link -> {
withStyle(
SpanStyle(
color = mobileAccent,
color = linkColor,
textDecoration = TextDecoration.Underline,
),
) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
}
}
is MarkdownImage -> {
@ -482,7 +484,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
}
}
else -> {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
}
}
current = current.next
@ -519,19 +521,21 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
}
private fun headingStyle(level: Int): TextStyle {
private fun headingStyle(level: Int, baseCallout: TextStyle): TextStyle {
return when (level.coerceIn(1, 6)) {
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
1 -> baseCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> baseCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> baseCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> baseCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> baseCallout.copy(fontWeight = FontWeight.SemiBold)
}
}
private data class InlineStyles(
val inlineCodeBg: Color,
val inlineCodeColor: Color,
val linkColor: Color,
val baseCallout: TextStyle,
)
private data class TableRenderRow(

View File

@ -19,6 +19,7 @@ import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
@ -85,7 +86,7 @@ private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
color = mobileCardSurface.copy(alpha = 0.9f),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
) {
androidx.compose.foundation.layout.Column(

View File

@ -36,7 +36,9 @@ import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCodeBg
import ai.openclaw.app.ui.mobileCodeBorder
import ai.openclaw.app.ui.mobileCodeText
import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileText
@ -194,6 +196,7 @@ fun ChatStreamingAssistantBubble(text: String) {
}
}
@Composable
private fun bubbleStyle(role: String): ChatBubbleStyle {
return when (role) {
"user" ->
@ -215,7 +218,7 @@ private fun bubbleStyle(role: String): ChatBubbleStyle {
else ->
ChatBubbleStyle(
alignEnd = false,
containerColor = Color.White,
containerColor = mobileCardSurface,
borderColor = mobileBorderStrong,
roleColor = mobileTextSecondary,
)
@ -239,7 +242,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = Color.White,
color = mobileCardSurface,
modifier = Modifier.fillMaxWidth(),
) {
Image(
@ -277,7 +280,7 @@ fun ChatCodeBlock(code: String, language: String?) {
Surface(
shape = RoundedCornerShape(8.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
border = BorderStroke(1.dp, mobileCodeBorder),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {

View File

@ -36,12 +36,15 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileDanger
import ai.openclaw.app.ui.mobileDangerSoft
import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
import java.io.ByteArrayOutputStream
@ -168,8 +171,8 @@ private fun ChatThreadSelector(
Surface(
onClick = { onSelectSession(entry.key) },
shape = RoundedCornerShape(14.dp),
color = if (active) mobileAccent else Color.White,
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
color = if (active) mobileAccent else mobileCardSurface,
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
tonalElevation = 0.dp,
shadowElevation = 0.dp,
) {
@ -190,7 +193,7 @@ private fun ChatThreadSelector(
private fun ChatErrorRail(errorText: String) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = androidx.compose.ui.graphics.Color.White,
color = mobileDangerSoft,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) {

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClawNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -0,0 +1,8 @@
# Generated Docs Artifacts
These baseline artifacts are generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
- Do not edit `config-baseline.json` by hand.
- Do not edit `config-baseline.jsonl` by hand.
- Regenerate it with `pnpm config:docs:gen`.
- Validate it in CI or locally with `pnpm config:docs:check`.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -186,20 +186,15 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
Kimi K2 model IDs:
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
<!-- markdownlint-enable MD037 -->
[//]: # "moonshot-kimi-k2-model-refs:start"
- `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo`
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-model-refs:end _/ && null}
<!-- markdownlint-enable MD037 -->
[//]: # "moonshot-kimi-k2-model-refs:end"
```json5
{

View File

@ -1242,7 +1242,6 @@
"group": "Security",
"pages": [
"security/formal-verification",
"security/README",
"security/THREAT-MODEL-ATLAS",
"security/CONTRIBUTING-THREAT-MODEL"
]
@ -1598,7 +1597,6 @@
"zh-CN/tools/apply-patch",
"zh-CN/brave-search",
"zh-CN/perplexity",
"zh-CN/tools/diffs",
"zh-CN/tools/elevated",
"zh-CN/tools/exec",
"zh-CN/tools/exec-approvals",

View File

@ -975,6 +975,7 @@ Periodic heartbeat runs.
model: "openai/gpt-5.2-mini",
includeReasoning: false,
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
session: "main",
to: "+15555550123",
directPolicy: "allow", // allow (default) | block
@ -992,6 +993,7 @@ Periodic heartbeat runs.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
- Heartbeats run full agent turns — shorter intervals burn more tokens.

View File

@ -22,7 +22,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
6. Optional: restrict heartbeats to active hours (local time).
6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
7. Optional: restrict heartbeats to active hours (local time).
Example config:
@ -35,6 +36,7 @@ Example config:
target: "last", // explicit delivery to last contact (default is "none")
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
isolatedSession: true, // optional: fresh session each run (no conversation history)
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too
},
@ -91,6 +93,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
model: "anthropic/claude-opus-4-6",
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
to: "+15551234567", // optional channel-specific override
accountId: "ops-bot", // optional multi-account channel id
@ -212,6 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `model`: optional model override for heartbeat runs (`provider/model`).
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
- `session`: optional session key for heartbeat runs.
- `main` (default): agent main session.
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
@ -380,6 +384,10 @@ off in group chats.
## Cost awareness
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep
`HEARTBEAT.md` small and consider a cheaper `model` or `target: "none"` if you
only want internal state updates.
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
- Keep `HEARTBEAT.md` small.
- Use `target: "none"` if you only want internal state updates.

View File

@ -289,7 +289,7 @@ Look for:
- Valid browser executable path.
- CDP profile reachability.
- Extension relay tab attachment for `profile="chrome-relay"`.
- Extension relay tab attachment (if an extension relay profile is configured).
Common signatures:

View File

@ -16,7 +16,7 @@ If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplex
## Getting a Perplexity API key
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
2. Generate an API key in the dashboard
3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment.

View File

@ -15,20 +15,15 @@ Kimi Coding with `kimi-coding/k2p5`.
Current Kimi K2 model IDs:
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-ids:start _/ && null}
<!-- markdownlint-enable MD037 -->
[//]: # "moonshot-kimi-k2-ids:start"
- `kimi-k2.5`
- `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview`
- `kimi-k2-thinking`
- `kimi-k2-thinking-turbo`
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-ids:end _/ && null}
<!-- markdownlint-enable MD037 -->
[//]: # "moonshot-kimi-k2-ids:end"
```bash
openclaw onboard --auth-choice moonshot-api-key

View File

@ -76,6 +76,7 @@ Historical note:
- [ ] `pnpm check`
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`

View File

@ -1,17 +0,0 @@
# OpenClaw Security & Trust
**Live:** [trust.openclaw.ai](https://trust.openclaw.ai)
## Documents
- [Threat Model](/security/THREAT-MODEL-ATLAS) - MITRE ATLAS-based threat model for the OpenClaw ecosystem
- [Contributing to the Threat Model](/security/CONTRIBUTING-THREAT-MODEL) - How to add threats, mitigations, and attack chains
## Reporting Vulnerabilities
See the [Trust page](https://trust.openclaw.ai) for full reporting instructions covering all repos.
## Contact
- **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) - Security & Trust
- Discord: #security channel

View File

@ -25,7 +25,7 @@ Note, selecting 'chromium-browser' instead of 'chromium'
chromium-browser is already the newest version (2:1snap1-0ubuntu2).
```
This is NOT a real browser it's just a wrapper.
This is NOT a real browser - it's just a wrapper.
### Solution 1: Install Google Chrome (Recommended)
@ -123,7 +123,7 @@ curl -s http://127.0.0.1:18791/tabs
### Problem: "Chrome extension relay is running, but no tab is connected"
Youre using the `chrome-relay` profile (extension relay). It expects the OpenClaw
You're using an extension relay profile. It expects the OpenClaw
browser extension to be attached to a live tab.
Fix options:

View File

@ -62,19 +62,14 @@ After upgrading OpenClaw:
## Use it (set gateway token once)
OpenClaw ships with a built-in browser profile named `chrome-relay` that targets the extension relay on the default port.
To use the extension relay, create a browser profile for it:
Before first attach, open extension Options and set:
- `Port` (default `18792`)
- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`)
Use it:
- CLI: `openclaw browser --browser-profile chrome-relay tabs`
- Agent tool: `browser` with `profile="chrome-relay"`
If you want a different name or a different relay port, create your own profile:
Then create a profile:
```bash
openclaw browser create-profile \
@ -84,6 +79,11 @@ openclaw browser create-profile \
--color "#00AA00"
```
Use it:
- CLI: `openclaw browser --browser-profile my-chrome tabs`
- Agent tool: `browser` with `profile="my-chrome"`
### Custom Gateway ports
If you're using a custom gateway port, the extension relay port is automatically derived:

View File

@ -160,13 +160,14 @@ Long options are validated fail-closed in safe-bin mode: unknown flags and ambig
abbreviations are rejected.
Denied flags by safe-bin profile:
<!-- SAFE_BIN_DENIED_FLAGS:START -->
[//]: # "SAFE_BIN_DENIED_FLAGS:START"
- `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r`
- `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f`
- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o`
- `wc`: `--files0-from`
<!-- SAFE_BIN_DENIED_FLAGS:END -->
[//]: # "SAFE_BIN_DENIED_FLAGS:END"
Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing
and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be

View File

@ -149,7 +149,11 @@ Lark国际版请使用 https://open.larksuite.com/app并在配置中设
**事件订阅** 页面:
1. 选择 **使用长连接接收事件**WebSocket 模式)
2. 添加事件:`im.message.receive_v1`(接收消息)
2. 添加事件:
- `im.message.receive_v1`
- `im.message.reaction.created_v1`
- `im.message.reaction.deleted_v1`
- `application.bot.menu_v6`
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
@ -435,7 +439,7 @@ openclaw pairing list feishu
| `/reset` | 重置对话会话 |
| `/model` | 查看/切换模型 |
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送
飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu <eventKey>`)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单
## 网关管理命令
@ -526,7 +530,11 @@ openclaw pairing list feishu
channels: {
feishu: {
streaming: true, // 启用流式卡片输出(默认 true
blockStreaming: true, // 启用块级流式(默认 true
blockStreamingCoalesce: {
enabled: true,
minDelayMs: 50,
maxDelayMs: 250,
},
},
},
}
@ -534,6 +542,40 @@ openclaw pairing list feishu
如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`
### 交互式卡片
OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。
- 默认路径:文本自动渲染或 Markdown 卡片
- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片
- 更新卡片:同一消息支持后续 patch/update
卡片按钮回调当前走文本回退路径:
- 若 `action.value.text` 存在,则作为入站文本继续处理
- 若 `action.value.command` 存在,则作为命令文本继续处理
- 其他对象值会序列化为 JSON 文本
这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。
### 表情反应
飞书渠道现已完整支持表情反应生命周期:
- 接收 `reaction created`
- 接收 `reaction deleted`
- 主动添加反应
- 主动删除自身反应
- 查询消息上的反应列表
是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制:
| 值 | 行为 |
| ----- | ---------------------------- |
| `off` | 不生成反应通知 |
| `own` | 仅当反应发生在机器人消息上时 |
| `all` | 所有可验证的反应都生成通知 |
### 消息引用
在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。
@ -653,14 +695,19 @@ openclaw pairing list feishu
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
| `channels.feishu.groupPolicy` | 群组策略 | `allowlist` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` |
| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
| `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` |
| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` |
| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` |
| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` |
---

View File

@ -57,6 +57,10 @@ export type BlueBubblesAccountConfig = {
allowPrivateNetwork?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>;
/** Channel health monitor overrides for this channel/account. */
healthMonitor?: {
enabled?: boolean;
};
};
export type BlueBubblesActionConfig = {

View File

@ -15,9 +15,12 @@ const {
mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu,
mockGetMessageFeishu,
mockListFeishuThreadMessages,
mockDownloadMessageResourceFeishu,
mockCreateFeishuClient,
mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
} = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
@ -26,6 +29,7 @@ const {
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"),
contentType: "video/mp4",
@ -40,6 +44,8 @@ const {
mainSessionKey: "agent:main:main",
matchedBy: "default",
})),
mockReadSessionUpdatedAt: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
}));
vi.mock("./reply-dispatcher.js", () => ({
@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
listFeishuThreadMessages: mockListFeishuThreadMessages,
}));
vi.mock("./media.js", () => ({
@ -70,11 +77,13 @@ function createRuntimeEnv(): RuntimeEnv {
}
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
const runtime = createRuntimeEnv();
await handleFeishuMessage({
cfg: params.cfg,
event: params.event,
runtime: createRuntimeEnv(),
runtime,
});
return runtime;
}
describe("buildFeishuAgentBody", () => {
@ -140,6 +149,10 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
mockReadSessionUpdatedAt.mockReturnValue(undefined);
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
mockResolveAgentRoute.mockReturnValue({
agentId: "main",
channel: "feishu",
@ -166,6 +179,12 @@ describe("handleFeishuMessage command authorization", () => {
resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
},
session: {
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(
() => ({}),
@ -1709,6 +1728,193 @@ describe("handleFeishuMessage command authorization", () => {
);
});
it("bootstraps topic thread context only for a new thread session", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "ou-topic-user",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup_existing_session",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
storePath: "/tmp/feishu-sessions.json",
sessionKey: "agent:main:feishu:dm:ou-attacker",
});
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
expect.objectContaining({
rootMessageId: "om_topic_root",
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("skips topic thread bootstrap when the thread session already exists", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: undefined,
ThreadHistoryBody: undefined,
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "user_topic_1",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic_sender",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-topic-user",
user_id: "user_topic_1",
},
},
message: {
message_id: "om_topic_followup_mixed_ids",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@ -9,6 +9,7 @@ import {
issuePairingChallenge,
normalizeAgentId,
recordPendingHistoryEntryIfEnabled,
resolveAgentOutboundIdentity,
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
@ -29,7 +30,7 @@ import {
import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
@ -1239,16 +1240,17 @@ export async function handleFeishuMessage(params: {
const mediaPayload = buildAgentMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined;
if (ctx.parentId) {
try {
const quotedMsg = await getMessageFeishu({
quotedMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.parentId,
accountId: account.accountId,
});
if (quotedMsg) {
quotedContent = quotedMsg.content;
if (quotedMessageInfo) {
quotedContent = quotedMessageInfo.content;
log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
);
@ -1258,6 +1260,11 @@ export async function handleFeishuMessage(params: {
}
}
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({
ctx,
@ -1309,13 +1316,150 @@ export async function handleFeishuMessage(params: {
}))
: undefined;
const threadContextBySessionKey = new Map<
string,
{
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
}
>();
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
let rootMessageFetched = false;
const getRootMessageInfo = async () => {
if (!ctx.rootId) {
return null;
}
if (!rootMessageFetched) {
rootMessageFetched = true;
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
rootMessageInfo = quotedMessageInfo;
} else {
try {
rootMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.rootId,
accountId: account.accountId,
});
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
rootMessageInfo = null;
}
}
}
return rootMessageInfo ?? null;
};
const resolveThreadContextForAgent = async (agentId: string, agentSessionKey: string) => {
const cached = threadContextBySessionKey.get(agentSessionKey);
if (cached) {
return cached;
}
const threadContext: {
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
} = {
threadLabel:
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
? `Feishu thread in ${ctx.chatId}`
: undefined,
};
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: agentSessionKey,
});
if (previousThreadSessionTimestamp) {
log(
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const rootMsg = await getRootMessageInfo();
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
if (feishuThreadId) {
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
}
if (!feishuThreadId) {
log(
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
try {
const threadMessages = await listFeishuThreadMessages({
cfg,
threadId: feishuThreadId,
currentMessageId: ctx.messageId,
rootMessageId: ctx.rootId,
limit: 20,
accountId: account.accountId,
});
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
const senderIds = new Set(
[ctx.senderOpenId, senderUserId]
.map((id) => id?.trim())
.filter((id): id is string => id !== undefined && id.length > 0),
);
const relevantMessages =
(senderScoped
? threadMessages.filter(
(msg) =>
msg.senderType === "app" ||
(msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
)
: threadMessages) ?? [];
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
const historyMessages = includeStarterInHistory
? relevantMessages
: relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime,
body: msg.content,
envelope: envelopeOptions,
});
});
threadContext.threadStarterBody = threadStarterBody;
threadContext.threadHistoryBody =
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
log(
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
);
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
}
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
};
// --- Shared context builder for dispatch ---
const buildCtxPayloadForAgent = (
const buildCtxPayloadForAgent = async (
agentId: string,
agentSessionKey: string,
agentAccountId: string,
wasMentioned: boolean,
) =>
core.channel.reply.finalizeInboundContext({
) => {
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
return core.channel.reply.finalizeInboundContext({
Body: combinedBody,
BodyForAgent: messageBody,
InboundHistory: inboundHistory,
@ -1335,6 +1479,12 @@ export async function handleFeishuMessage(params: {
Surface: "feishu" as const,
MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined,
ThreadStarterBody: threadContext.threadStarterBody,
ThreadHistoryBody: threadContext.threadHistoryBody,
ThreadLabel: threadContext.threadLabel,
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
// ID and would produce invalid reply targets downstream.
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
Timestamp: Date.now(),
WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized,
@ -1343,6 +1493,7 @@ export async function handleFeishuMessage(params: {
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
...mediaPayload,
});
};
// Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time
@ -1402,7 +1553,8 @@ export async function handleFeishuMessage(params: {
}
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentCtx = buildCtxPayloadForAgent(
const agentCtx = await buildCtxPayloadForAgent(
agentId,
agentSessionKey,
route.accountId,
ctx.mentionedBot && agentId === activeAgentId,
@ -1410,6 +1562,7 @@ export async function handleFeishuMessage(params: {
if (agentId === activeAgentId) {
// Active agent: real Feishu dispatcher (responds on Feishu)
const identity = resolveAgentOutboundIdentity(cfg, agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId,
@ -1422,6 +1575,7 @@ export async function handleFeishuMessage(params: {
threadReply,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
identity,
messageCreateTimeMs,
});
@ -1502,12 +1656,14 @@ export async function handleFeishuMessage(params: {
);
} else {
// --- Single-agent dispatch (existing behavior) ---
const ctxPayload = buildCtxPayloadForAgent(
const ctxPayload = await buildCtxPayloadForAgent(
route.agentId,
route.sessionKey,
route.accountId,
ctx.mentionedBot,
);
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg,
agentId: route.agentId,
@ -1520,6 +1676,7 @@ export async function handleFeishuMessage(params: {
threadReply,
mentionTargets: ctx.mentionTargets,
accountId: account.accountId,
identity,
messageCreateTimeMs,
});

View File

@ -20,6 +20,20 @@ export type FeishuCardActionEvent = {
};
};
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export async function handleFeishuCardAction(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
@ -30,21 +44,7 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
// Extract action value
const actionValue = event.action.value;
let content = "";
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
content = actionValue.text;
} else if ("command" in actionValue && typeof actionValue.command === "string") {
content = actionValue.command;
} else {
content = JSON.stringify(actionValue);
}
} else {
content = String(actionValue);
}
const content = buildCardActionTextFallback(event);
// Construct a synthetic message event
const messageEvent: FeishuMessageEvent = {

View File

@ -2,11 +2,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
vi.mock("./reactions.js", () => ({
addReactionFeishu: vi.fn(),
listReactionsFeishu: listReactionsFeishuMock,
removeReactionFeishu: vi.fn(),
}));
import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => {
@ -46,3 +53,114 @@ describe("feishuPlugin.status.probeAccount", () => {
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});
describe("feishuPlugin actions", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: true,
},
},
},
} as OpenClawConfig;
it("does not advertise reactions when disabled via actions config", () => {
const disabledCfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: false,
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
});
it("advertises reactions when any enabled configured account allows them", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
defaultAccount: "main",
actions: {
reactions: false,
},
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
actions: {
reactions: false,
},
},
secondary: {
appId: "cli_secondary",
appSecret: "secret_secondary",
enabled: true,
actions: {
reactions: true,
},
},
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
});
it("requires clearAll=true before removing all bot reactions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
});
it("throws for unsupported Feishu send actions without card payload", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", message: "hello" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow('Unsupported Feishu action: "send"');
});
it("allows explicit clearAll=true when removing all bot reactions", async () => {
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r1", operatorType: "app" },
{ reactionId: "r2", operatorType: "app" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
cfg,
accountId: undefined,
} as never);
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
accountId: undefined,
});
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
});
});

View File

@ -5,18 +5,23 @@ import {
} from "openclaw/plugin-sdk/compat";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
createActionGate,
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/feishu";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu";
import {
resolveFeishuAccount,
resolveFeishuCredentials,
listFeishuAccountIds,
listEnabledFeishuAccounts,
resolveDefaultFeishuAccountId,
} from "./accounts.js";
import { FeishuConfigSchema } from "./config-schema.js";
import {
listFeishuDirectoryPeers,
listFeishuDirectoryGroups,
@ -27,7 +32,8 @@ import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js";
import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
@ -42,22 +48,6 @@ const meta: ChannelMeta = {
order: 70,
};
const secretInputJsonSchema = {
oneOf: [
{ type: "string" },
{
type: "object",
additionalProperties: false,
required: ["source", "provider", "id"],
properties: {
source: { type: "string", enum: ["env", "file", "exec"] },
provider: { type: "string", minLength: 1 },
id: { type: "string", minLength: 1 },
},
},
],
} as const;
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
@ -82,6 +72,32 @@ function setFeishuNamedAccountEnabled(
};
}
function isFeishuReactionsActionEnabled(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
}): boolean {
if (!params.account.enabled || !params.account.configured) {
return false;
}
const gate = createActionGate(
(params.account.config.actions ??
(params.cfg.channels?.feishu as { actions?: unknown } | undefined)?.actions) as Record<
string,
boolean | undefined
>,
);
return gate("reactions");
}
function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
for (const account of listEnabledFeishuAccounts(cfg)) {
if (isFeishuReactionsActionEnabled({ cfg, account })) {
return true;
}
}
return false;
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu",
meta: {
@ -120,69 +136,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
},
reload: { configPrefixes: ["channels.feishu"] },
configSchema: {
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
defaultAccount: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookHost: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
groupAllowFrom: {
type: "array",
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
},
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
replyInThread: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
chunkMode: { type: "string", enum: ["length", "newline"] },
mediaMaxMb: { type: "number", minimum: 0 },
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
},
},
},
},
},
},
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
@ -255,6 +209,172 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
},
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
},
actions: {
listActions: ({ cfg }) => {
if (listEnabledFeishuAccounts(cfg).length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>();
if (areAnyFeishuReactionActionsEnabled(cfg)) {
actions.add("react");
actions.add("reactions");
}
return Array.from(actions);
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
);
},
handleAction: async (ctx) => {
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
if (
(ctx.action === "react" || ctx.action === "reactions") &&
!isFeishuReactionsActionEnabled({ cfg: ctx.cfg, account })
) {
throw new Error("Feishu reactions are disabled via actions.reactions.");
}
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }],
details: { error: "Feishu card send requires a target (to)." },
};
}
const replyToMessageId =
typeof ctx.params.replyTo === "string"
? ctx.params.replyTo.trim() || undefined
: undefined;
const result = await sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ ok: true, channel: "feishu", ...result }),
},
],
details: { ok: true, channel: "feishu", ...result },
};
}
if (ctx.action === "react") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reaction requires messageId.");
}
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
const remove = ctx.params.remove === true;
const clearAll = ctx.params.clearAll === true;
if (remove) {
if (!emoji) {
throw new Error("Emoji is required to remove a Feishu reaction.");
}
const matches = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
const ownReaction = matches.find((entry) => entry.operatorType === "app");
if (!ownReaction) {
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) },
],
details: { ok: true, removed: null },
};
}
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: ownReaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) },
],
details: { ok: true, removed: emoji },
};
}
if (!emoji) {
if (!clearAll) {
throw new Error(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
let removed = 0;
for (const reaction of reactions.filter((entry) => entry.operatorType === "app")) {
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: reaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
removed += 1;
}
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }],
details: { ok: true, removed },
};
}
await addReactionFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }],
details: { ok: true, added: emoji },
};
}
if (ctx.action === "reactions") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reactions lookup requires messageId.");
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }],
details: { ok: true, reactions },
};
}
throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`);
},
},
security: {
collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId });

View File

@ -217,6 +217,26 @@ describe("FeishuConfigSchema optimization flags", () => {
});
});
describe("FeishuConfigSchema actions", () => {
it("accepts top-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
actions: { reactions: false },
});
expect(result.actions?.reactions).toBe(false);
});
it("accepts account-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {
actions: { reactions: false },
},
},
});
expect(result.accounts?.main?.actions?.reactions).toBe(false);
});
});
describe("FeishuConfigSchema defaultAccount", () => {
it("accepts defaultAccount when it matches an account key", () => {
const result = FeishuConfigSchema.safeParse({

View File

@ -3,6 +3,13 @@ import { z } from "zod";
export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const ChannelActionsSchema = z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional();
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.union([
z.enum(["open", "allowlist", "disabled"]),
@ -170,6 +177,7 @@ const FeishuSharedConfigShape = {
renderMode: RenderModeSchema,
streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema,
actions: ChannelActionsSchema,
replyInThread: ReplyInThreadSchema,
reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(),

View File

@ -38,6 +38,10 @@ export type FeishuReactionCreatedEvent = {
action_time?: string;
};
export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
reaction_id?: string;
};
type ResolveReactionSyntheticEventParams = {
cfg: ClawdbotConfig;
accountId: string;
@ -47,6 +51,7 @@ type ResolveReactionSyntheticEventParams = {
verificationTimeoutMs?: number;
logger?: (message: string) => void;
uuid?: () => string;
action?: "created" | "deleted";
};
export async function resolveReactionSyntheticEvent(
@ -61,6 +66,7 @@ export async function resolveReactionSyntheticEvent(
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
logger,
uuid = () => crypto.randomUUID(),
action = "created",
} = params;
const emoji = event.reaction_type?.emoji_type;
@ -129,7 +135,10 @@ export async function resolveReactionSyntheticEvent(
chat_type: syntheticChatType,
message_type: "text",
content: JSON.stringify({
text: `[reacted with ${emoji} to message ${messageId}]`,
text:
action === "deleted"
? `[removed reaction ${emoji} from message ${messageId}]`
: `[reacted with ${emoji} to message ${messageId}]`,
}),
},
};
@ -253,6 +262,19 @@ function registerEventHandlers(
const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error;
const enqueue = createChatQueue();
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
if (fireAndForget) {
void params.task().catch((err) => {
error(`${params.errorMessage}: ${String(err)}`);
});
return;
}
try {
await params.task();
} catch (err) {
error(`${params.errorMessage}: ${String(err)}`);
}
};
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
const chatId = event.message.chat_id?.trim() || "unknown";
const task = () =>
@ -428,23 +450,102 @@ function registerEventHandlers(
}
},
"im.message.reaction.created_v1": async (data) => {
const processReaction = async () => {
const event = data as FeishuReactionCreatedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
});
if (!syntheticEvent) {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction event`,
task: async () => {
const event = data as FeishuReactionCreatedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"im.message.reaction.deleted_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
task: async () => {
const event = data as FeishuReactionDeletedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
action: "deleted",
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"application.bot.menu_v6": async (data) => {
try {
const event = data as {
event_key?: string;
timestamp?: number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
const eventKey = event.event_key?.trim();
if (!operatorOpenId || !eventKey) {
return;
}
const syntheticEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: operatorOpenId,
user_id: event.operator?.operator_id?.user_id,
union_id: event.operator?.operator_id?.union_id,
},
sender_type: "user",
},
message: {
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
chat_id: `p2p:${operatorOpenId}`,
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({
text: `/menu ${eventKey}`,
}),
},
};
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
@ -452,29 +553,15 @@ function registerEventHandlers(
});
if (fireAndForget) {
promise.catch((err) => {
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
});
return;
}
await promise;
};
if (fireAndForget) {
void processReaction().catch((err) => {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
});
return;
}
try {
await processReaction();
} catch (err) {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
}
},
"im.message.reaction.deleted_v1": async () => {
// Ignore reaction removals
},
"card.action.trigger": async (data: unknown) => {
try {
const event = data as unknown as FeishuCardActionEvent;

View File

@ -0,0 +1,67 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it } from "vitest";
import {
resolveReactionSyntheticEvent,
type FeishuReactionCreatedEvent,
} from "./monitor.account.js";
const cfg = {} as ClawdbotConfig;
function makeReactionEvent(
overrides: Partial<FeishuReactionCreatedEvent> = {},
): FeishuReactionCreatedEvent {
return {
message_id: "om_msg1",
reaction_type: { emoji_type: "THUMBSUP" },
operator_type: "user",
user_id: { open_id: "ou_user1" },
...overrides,
};
}
describe("Feishu reaction lifecycle", () => {
it("builds a created synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
});
expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
});
it("builds a deleted synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
action: "deleted",
});
expect(result?.message.content).toBe(
'{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
);
});
});

View File

@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock,
@ -14,6 +15,7 @@ vi.mock("./media.js", () => ({
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
}));
vi.mock("./runtime.js", () => ({
@ -33,6 +35,7 @@ function resetOutboundMocks() {
vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
}
@ -132,7 +135,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
accountId: "main",
});
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat_1",
text: "| a | b |\n| - | - |",
@ -207,7 +210,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
);
});
it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => {
it("forwards replyToId to sendStructuredCardFeishu when renderMode=card", async () => {
await sendText({
cfg: {
channels: {
@ -222,7 +225,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
accountId: "main",
});
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_reply_target",
}),

View File

@ -4,7 +4,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
const raw = text?.trim();
@ -81,7 +81,16 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
sendText: async ({
cfg,
to,
text,
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text,
@ -104,6 +113,29 @@ export const feishuOutbound: ChannelOutboundAdapter = {
}
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
const result = await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
return { channel: "feishu", ...result };
}
const result = await sendOutboundText({
cfg,
to,

View File

@ -4,6 +4,7 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
@ -17,6 +18,7 @@ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
}));
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
@ -56,6 +58,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
vi.clearAllMocks();
streamingInstances.length = 0;
sendMediaFeishuMock.mockResolvedValue(undefined);
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
@ -255,11 +258,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
replyToMessageId: undefined,
replyInThread: undefined,
rootId: "om_root_topic",
});
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: undefined,
replyInThread: undefined,
rootId: "om_root_topic",
header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
@ -275,7 +284,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
note: "Agent: agent",
});
});
it("delivers distinct final payloads after streaming close", async () => {
@ -287,9 +298,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(2);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```", {
note: "Agent: agent",
});
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
expect(streamingInstances[1].close).toHaveBeenCalledWith(
"```md\n完整回复第一段 + 第二段\n```",
{
note: "Agent: agent",
},
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
@ -303,7 +321,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
note: "Agent: agent",
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
@ -367,7 +387,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
note: "Agent: agent",
});
});
it("sends media-only payloads as attachments", async () => {
@ -436,7 +458,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
});
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
@ -454,7 +476,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
});
await options.deliver({ text: "card text" }, { kind: "final" });
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
@ -462,6 +484,126 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
);
});
it("streams reasoning content as blockquote before answer", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
// Core agent sends pre-formatted text from formatReasoningMessage
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" });
result.replyOptions.onReasoningStream?.({
text: "Reasoning:\n_thinking step 1_\n_step 2_",
});
result.replyOptions.onPartialReply?.({ text: "answer part" });
result.replyOptions.onReasoningEnd?.();
await options.deliver({ text: "answer part final" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) => c[0]);
const reasoningUpdate = updateCalls.find((c: string) => c.includes("Thinking"));
expect(reasoningUpdate).toContain("> 💭 **Thinking**");
// formatReasoningPrefix strips "Reasoning:" prefix and italic markers
expect(reasoningUpdate).toContain("> thinking step");
expect(reasoningUpdate).not.toContain("Reasoning:");
expect(reasoningUpdate).not.toMatch(/> _.*_/);
const combinedUpdate = updateCalls.find(
(c: string) => c.includes("Thinking") && c.includes("---"),
);
expect(combinedUpdate).toBeDefined();
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).toContain("> 💭 **Thinking**");
expect(closeArg).toContain("---");
expect(closeArg).toContain("answer part final");
});
it("provides onReasoningStream and onReasoningEnd when streaming is enabled", () => {
const { result } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
});
it("omits reasoning callbacks when streaming is disabled", () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: false,
},
});
const { result } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
expect(result.replyOptions.onReasoningStream).toBeUndefined();
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
});
it("renders reasoning-only card when no answer text arrives", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" });
result.replyOptions.onReasoningEnd?.();
await options.onIdle?.();
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).toContain("> 💭 **Thinking**");
expect(closeArg).toContain("> deep thought");
expect(closeArg).not.toContain("Reasoning:");
expect(closeArg).not.toContain("---");
});
it("ignores empty reasoning payloads", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "" });
result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).not.toContain("Thinking");
expect(closeArg).toBe("```ts\ncode\n```");
});
it("deduplicates final text by raw answer payload, not combined card text", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" });
result.replyOptions.onReasoningEnd?.();
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
// Deliver the same raw answer text again — should be deduped
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
// No second streaming session since the raw answer text matches
expect(streamingInstances).toHaveLength(1);
});
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
const { options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
@ -471,10 +613,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
replyToMessageId: "om_msg",
replyInThread: true,
});
expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,
header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
});
it("disables streaming for thread replies and keeps reply metadata", async () => {
@ -488,7 +636,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(0);
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_msg",
replyInThread: true,

View File

@ -3,6 +3,7 @@ import {
createTypingCallbacks,
logTypingFailure,
type ClawdbotConfig,
type OutboundIdentity,
type ReplyPayload,
type RuntimeEnv,
} from "openclaw/plugin-sdk/feishu";
@ -12,7 +13,12 @@ import { sendMediaFeishu } from "./media.js";
import type { MentionTarget } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
import {
sendMarkdownCardFeishu,
sendMessageFeishu,
sendStructuredCardFeishu,
type CardHeaderConfig,
} from "./send.js";
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@ -36,6 +42,36 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
}
/** Build a card header from agent identity config. */
function resolveCardHeader(
agentId: string,
identity: OutboundIdentity | undefined,
): CardHeaderConfig {
const name = identity?.name?.trim() || agentId;
const emoji = identity?.emoji?.trim();
return {
title: emoji ? `${emoji} ${name}` : name,
template: identity?.theme ?? "blue",
};
}
/** Build a card note footer from agent identity and model context. */
function resolveCardNote(
agentId: string,
identity: OutboundIdentity | undefined,
prefixCtx: { model?: string; provider?: string },
): string {
const name = identity?.name?.trim() || agentId;
const parts: string[] = [`Agent: ${name}`];
if (prefixCtx.model) {
parts.push(`Model: ${prefixCtx.model}`);
}
if (prefixCtx.provider) {
parts.push(`Provider: ${prefixCtx.provider}`);
}
return parts.join(" | ");
}
export type CreateFeishuReplyDispatcherParams = {
cfg: ClawdbotConfig;
agentId: string;
@ -50,6 +86,7 @@ export type CreateFeishuReplyDispatcherParams = {
rootId?: string;
mentionTargets?: MentionTarget[];
accountId?: string;
identity?: OutboundIdentity;
/** Epoch ms when the inbound message was created. Used to suppress typing
* indicators on old/replayed messages after context compaction (#30418). */
messageCreateTimeMs?: number;
@ -68,6 +105,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
rootId,
mentionTargets,
accountId,
identity,
} = params;
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const threadReplyMode = threadReply === true;
@ -143,11 +181,39 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
let streaming: FeishuStreamingSession | null = null;
let streamText = "";
let lastPartial = "";
let reasoningText = "";
const deliveredFinalTexts = new Set<string>();
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null;
type StreamTextUpdateMode = "snapshot" | "delta";
const formatReasoningPrefix = (thinking: string): string => {
if (!thinking) return "";
const withoutLabel = thinking.replace(/^Reasoning:\n/, "");
const plain = withoutLabel.replace(/^_(.*)_$/gm, "$1");
const lines = plain.split("\n").map((line) => `> ${line}`);
return `> 💭 **Thinking**\n${lines.join("\n")}`;
};
const buildCombinedStreamText = (thinking: string, answer: string): string => {
const parts: string[] = [];
if (thinking) parts.push(formatReasoningPrefix(thinking));
if (thinking && answer) parts.push("\n\n---\n\n");
if (answer) parts.push(answer);
return parts.join("");
};
const flushStreamingCardUpdate = (combined: string) => {
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(combined);
}
});
};
const queueStreamingUpdate = (
nextText: string,
options?: {
@ -167,14 +233,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const mode = options?.mode ?? "snapshot";
streamText =
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(streamText);
}
});
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
};
const queueReasoningUpdate = (nextThinking: string) => {
if (!nextThinking) return;
reasoningText = nextThinking;
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
};
const startStreaming = () => {
@ -194,10 +259,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
);
try {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await streaming.start(chatId, resolveReceiveIdType(chatId), {
replyToMessageId,
replyInThread: effectiveReplyInThread,
rootId,
header: cardHeader,
note: cardNote,
});
} catch (error) {
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
@ -213,16 +282,18 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
await partialUpdateQueue;
if (streaming?.isActive()) {
let text = streamText;
let text = buildCombinedStreamText(reasoningText, streamText);
if (mentionTargets?.length) {
text = buildMentionedCardContent(mentionTargets, text);
}
await streaming.close(text);
const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await streaming.close(text, { note: finalNote });
}
streaming = null;
streamingStartPromise = null;
streamText = "";
lastPartial = "";
reasoningText = "";
};
const sendChunkedTextReply = async (params: {
@ -292,6 +363,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (shouldDeliverText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
let first = true;
if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as
@ -340,7 +412,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
if (useCard) {
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
for (const chunk of core.channel.text.chunkTextWithMode(
text,
textChunkLimit,
chunkMode,
)) {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
first = false;
}
if (info?.kind === "final") {
deliveredFinalTexts.add(text);
}
} else {
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
}
@ -392,6 +486,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
});
}
: undefined,
onReasoningStream: streamingEnabled
? (payload: ReplyPayload) => {
if (!payload.text) {
return;
}
startStreaming();
queueReasoningUpdate(payload.text);
}
: undefined,
onReasoningEnd: streamingEnabled ? () => {} : undefined,
},
markDispatchIdle,
};

View File

@ -1,12 +1,19 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMessageFeishu } from "./send.js";
import {
buildStructuredCard,
getMessageFeishu,
listFeishuThreadMessages,
resolveFeishuCardTemplate,
} from "./send.js";
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
vi.hoisted(() => ({
mockClientGet: vi.fn(),
mockClientList: vi.fn(),
mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(),
}));
vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
@ -27,6 +34,7 @@ describe("getMessageFeishu", () => {
im: {
message: {
get: mockClientGet,
list: mockClientList,
},
},
});
@ -165,4 +173,98 @@ describe("getMessageFeishu", () => {
}),
);
});
it("reuses the same content parsing for thread history messages", async () => {
mockClientList.mockResolvedValueOnce({
code: 0,
data: {
items: [
{
message_id: "om_root",
msg_type: "text",
body: {
content: JSON.stringify({ text: "root starter" }),
},
},
{
message_id: "om_card",
msg_type: "interactive",
body: {
content: JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
},
}),
},
sender: {
id: "app_1",
sender_type: "app",
},
create_time: "1710000000000",
},
{
message_id: "om_file",
msg_type: "file",
body: {
content: JSON.stringify({ file_key: "file_v3_123" }),
},
sender: {
id: "ou_1",
sender_type: "user",
},
create_time: "1710000001000",
},
],
},
});
const result = await listFeishuThreadMessages({
cfg: {} as ClawdbotConfig,
threadId: "omt_1",
rootMessageId: "om_root",
});
expect(result).toEqual([
expect.objectContaining({
messageId: "om_file",
contentType: "file",
content: "[file message]",
}),
expect.objectContaining({
messageId: "om_card",
contentType: "interactive",
content: "hello from card 2.0",
}),
]);
});
});
describe("resolveFeishuCardTemplate", () => {
it("accepts supported Feishu templates", () => {
expect(resolveFeishuCardTemplate(" purple ")).toBe("purple");
});
it("drops unsupported free-form identity themes", () => {
expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined();
});
});
describe("buildStructuredCard", () => {
it("falls back to blue when the header template is unsupported", () => {
const card = buildStructuredCard("hello", {
header: {
title: "Agent",
template: "space lobster",
},
});
expect(card).toEqual(
expect.objectContaining({
header: {
title: { tag: "plain_text", content: "Agent" },
template: "blue",
},
}),
);
});
});

View File

@ -10,6 +10,21 @@ import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
const FEISHU_CARD_TEMPLATES = new Set([
"blue",
"green",
"red",
"orange",
"purple",
"indigo",
"wathet",
"turquoise",
"yellow",
"grey",
"carmine",
"violet",
"lime",
]);
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
@ -65,6 +80,7 @@ type FeishuMessageGetItem = {
message_id?: string;
chat_id?: string;
chat_type?: FeishuChatType;
thread_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: FeishuMessageSender;
@ -151,13 +167,19 @@ function parseInteractiveCardContent(parsed: unknown): string {
return "[Interactive Card]";
}
const candidate = parsed as { elements?: unknown };
if (!Array.isArray(candidate.elements)) {
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
const elements = Array.isArray(candidate.elements)
? candidate.elements
: Array.isArray(candidate.body?.elements)
? candidate.body!.elements
: null;
if (!elements) {
return "[Interactive Card]";
}
const texts: string[] = [];
for (const element of candidate.elements) {
for (const element of elements) {
if (!element || typeof element !== "object") {
continue;
}
@ -177,7 +199,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
return texts.join("\n").trim() || "[Interactive Card]";
}
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
function parseFeishuMessageContent(rawContent: string, msgType: string): string {
if (!rawContent) {
return "";
}
@ -218,6 +240,30 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
return `[${msgType || "unknown"} message]`;
}
function parseFeishuMessageItem(
item: FeishuMessageGetItem,
fallbackMessageId?: string,
): FeishuMessageInfo {
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
return {
messageId: item.message_id ?? fallbackMessageId ?? "",
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content: parseFeishuMessageContent(rawContent, msgType),
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
threadId: item.thread_id || undefined,
};
}
/**
* Get a message by its ID.
* Useful for fetching quoted/replied message content.
@ -255,29 +301,98 @@ export async function getMessageFeishu(params: {
return null;
}
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
const content = parseQuotedMessageContent(rawContent, msgType);
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content,
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
};
return parseFeishuMessageItem(item, messageId);
} catch {
return null;
}
}
export type FeishuThreadMessageInfo = {
messageId: string;
senderId?: string;
senderType?: string;
content: string;
contentType: string;
createTime?: number;
};
/**
* List messages in a Feishu thread (topic).
* Uses container_id_type=thread to directly query thread messages,
* which includes both the root message and all replies (including bot replies).
*/
export async function listFeishuThreadMessages(params: {
cfg: ClawdbotConfig;
threadId: string;
currentMessageId?: string;
/** Exclude the root message (already provided separately as ThreadStarterBody). */
rootMessageId?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuThreadMessageInfo[]> {
const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = (await client.im.message.list({
params: {
container_id_type: "thread",
container_id: threadId,
// Fetch newest messages first so long threads keep the most recent turns.
// Results are reversed below to restore chronological order.
sort_type: "ByCreateTimeDesc",
page_size: Math.min(limit + 1, 50),
},
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<
{
message_id?: string;
root_id?: string;
parent_id?: string;
} & FeishuMessageGetItem
>;
};
};
if (response.code !== 0) {
throw new Error(
`Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
);
}
const items = response.data?.items ?? [];
const results: FeishuThreadMessageInfo[] = [];
for (const item of items) {
if (currentMessageId && item.message_id === currentMessageId) continue;
if (rootMessageId && item.message_id === rootMessageId) continue;
const parsed = parseFeishuMessageItem(item);
results.push({
messageId: parsed.messageId,
senderId: parsed.senderId,
senderType: parsed.senderType,
content: parsed.content,
contentType: parsed.contentType,
createTime: parsed.createTime,
});
if (results.length >= limit) break;
}
// Restore chronological order (oldest first) since we fetched newest-first.
results.reverse();
return results;
}
export type SendFeishuMessageParams = {
cfg: ClawdbotConfig;
to: string;
@ -418,6 +533,77 @@ export function buildMarkdownCard(text: string): Record<string, unknown> {
};
}
/** Header configuration for structured Feishu cards. */
export type CardHeaderConfig = {
/** Header title text, e.g. "💻 Coder" */
title: string;
/** Feishu header color template (blue, green, red, orange, purple, grey, etc.). Defaults to "blue". */
template?: string;
};
export function resolveFeishuCardTemplate(template?: string): string | undefined {
const normalized = template?.trim().toLowerCase();
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
return undefined;
}
return normalized;
}
/**
* Build a Feishu interactive card with optional header and note footer.
* When header/note are omitted, behaves identically to buildMarkdownCard.
*/
export function buildStructuredCard(
text: string,
options?: {
header?: CardHeaderConfig;
note?: string;
},
): Record<string, unknown> {
const elements: Record<string, unknown>[] = [{ tag: "markdown", content: text }];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({ tag: "markdown", content: `<font color='grey'>${options.note}</font>` });
}
const card: Record<string, unknown> = {
schema: "2.0",
config: { wide_screen_mode: true },
body: { elements },
};
if (options?.header) {
card.header = {
title: { tag: "plain_text", content: options.header.title },
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
};
}
return card;
}
/**
* Send a message as a structured card with optional header and note.
*/
export async function sendStructuredCardFeishu(params: {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
mentions?: MentionTarget[];
accountId?: string;
header?: CardHeaderConfig;
note?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } =
params;
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildStructuredCard(cardText, { header, note });
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
}
/**
* Send a message as a markdown card (interactive message).
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)

View File

@ -4,10 +4,25 @@
import type { Client } from "@larksuiteoapi/node-sdk";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import type { FeishuDomain } from "./types.js";
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
type CardState = {
cardId: string;
messageId: string;
sequence: number;
currentText: string;
hasNote: boolean;
};
/** Options for customising the initial streaming card appearance. */
export type StreamingCardOptions = {
/** Optional header with title and color template. */
header?: CardHeaderConfig;
/** Optional grey note footer text. */
note?: string;
};
/** Optional header for streaming cards (title bar with color template) */
export type StreamingCardHeader = {
@ -152,6 +167,7 @@ export class FeishuStreamingSession {
private log?: (msg: string) => void;
private lastUpdateTime = 0;
private pendingText: string | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private updateThrottleMs = 100; // Throttle updates to max 10/sec
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
@ -163,13 +179,24 @@ export class FeishuStreamingSession {
async start(
receiveId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
options?: StreamingStartOptions,
options?: StreamingCardOptions & StreamingStartOptions,
): Promise<void> {
if (this.state) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
const elements: Record<string, unknown>[] = [
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({
tag: "markdown",
content: `<font color='grey'>${options.note}</font>`,
element_id: "note",
});
}
const cardJson: Record<string, unknown> = {
schema: "2.0",
config: {
@ -177,14 +204,12 @@ export class FeishuStreamingSession {
summary: { content: "[Generating...]" },
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
},
body: {
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
},
body: { elements },
};
if (options?.header) {
cardJson.header = {
title: { tag: "plain_text", content: options.header.title },
template: options.header.template ?? "blue",
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
};
}
@ -257,7 +282,13 @@ export class FeishuStreamingSession {
throw new Error(`Send card failed: ${sendRes.msg}`);
}
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
this.state = {
cardId,
messageId: sendRes.data.message_id,
sequence: 1,
currentText: "",
hasNote: !!options?.note,
};
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
}
@ -307,6 +338,10 @@ export class FeishuStreamingSession {
}
this.pendingText = null;
this.lastUpdateTime = now;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.queue = this.queue.then(async () => {
if (!this.state || this.closed) {
@ -322,11 +357,44 @@ export class FeishuStreamingSession {
await this.queue;
}
async close(finalText?: string): Promise<void> {
private async updateNoteContent(note: string): Promise<void> {
if (!this.state || !this.state.hasNote) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
await fetchWithSsrFGuard({
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
init: {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: `<font color='grey'>${note}</font>`,
sequence: this.state.sequence,
uuid: `n_${this.state.cardId}_${this.state.sequence}`,
}),
},
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.note-update",
})
.then(async ({ release }) => {
await release();
})
.catch((e) => this.log?.(`Note update failed: ${String(e)}`));
}
async close(finalText?: string, options?: { note?: string }): Promise<void> {
if (!this.state || this.closed) {
return;
}
this.closed = true;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
await this.queue;
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
@ -339,6 +407,11 @@ export class FeishuStreamingSession {
this.state.currentText = text;
}
// Update note with final model/provider info
if (options?.note) {
await this.updateNoteContent(options.note);
}
// Close streaming mode
this.state.sequence += 1;
await fetchWithSsrFGuard({
@ -364,8 +437,11 @@ export class FeishuStreamingSession {
await release();
})
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
const finalState = this.state;
this.state = null;
this.pendingText = null;
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
}
isActive(): boolean {

View File

@ -72,6 +72,8 @@ export type FeishuMessageInfo = {
content: string;
contentType: string;
createTime?: number;
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
threadId?: string;
};
export type FeishuProbeResult = BaseProbeResult<string> & {

View File

@ -15,8 +15,8 @@ import {
withResolvedWebhookRequestPipeline,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS,
resolveClientIp,
} from "openclaw/plugin-sdk/zalo";
import { resolveClientIp } from "../../../src/gateway/net.js";
import type { ResolvedZaloAccount } from "./accounts.js";
import type { ZaloFetch, ZaloUpdate } from "./api.js";
import type { ZaloRuntimeEnv } from "./monitor.js";

View File

@ -477,7 +477,37 @@ describe("zalouser monitor group mention gating", () => {
});
});
it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => {
it("allows allowlisted group replies without inheriting the DM allowlist", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
replyPayload: { text: "ok" },
});
await __testing.processMessage({
message: createGroupMessage({
content: "ping @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
senderId: "456",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
allowFrom: ["123"],
groups: {
"group:g-1": { allow: true, requireMention: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("blocks group messages when sender is not in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
@ -493,6 +523,7 @@ describe("zalouser monitor group mention gating", () => {
...createAccount().config,
groupPolicy: "allowlist",
allowFrom: ["999"],
groupAllowFrom: ["999"],
},
},
config: createConfig(),

View File

@ -27,6 +27,7 @@ import {
resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization,
resolveSenderScopedGroupPolicy,
sendMediaWithLeadingCaption,
summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce,
@ -349,6 +350,10 @@ async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
groupPolicy,
groupAllowFrom: configGroupAllowFrom,
});
const shouldComputeCommandAuth = core.channel.commands.shouldComputeCommandAuthorized(
commandBody,
config,
@ -360,10 +365,11 @@ async function processMessage(
const accessDecision = resolveDmGroupAccessWithLists({
isGroup,
dmPolicy,
groupPolicy,
groupPolicy: senderGroupPolicy,
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, allowFrom),
});
if (isGroup && accessDecision.decision !== "allow") {

View File

@ -233,6 +233,8 @@
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
"config:docs:gen": "node --import tsx scripts/generate-config-doc-baseline.ts --write",
"deadcode:ci": "pnpm deadcode:report:ci:knip",
"deadcode:knip": "pnpm dlx knip --config knip.config.ts --isolate-workspaces --production --no-progress --reporter compact --files --dependencies",
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
@ -298,7 +300,7 @@
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
"protocol:gen": "node --import tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts",
"release:check": "node --import tsx scripts/release-check.ts",
"release:check": "pnpm config:docs:check && node --import tsx scripts/release-check.ts",
"release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts",
"start": "node scripts/run-node.mjs",
"test": "node scripts/test-parallel.mjs",
@ -449,7 +451,8 @@
"node-domexception": "npm:@nolyfill/domexception@^1.0.28",
"@sinclair/typebox": "0.34.48",
"tar": "7.5.11",
"tough-cookie": "4.1.3"
"tough-cookie": "4.1.3",
"yauzl": "3.2.1"
},
"onlyBuiltDependencies": [
"@lydell/node-pty",

19
pnpm-lock.yaml generated
View File

@ -18,6 +18,7 @@ overrides:
'@sinclair/typebox': 0.34.48
tar: 7.5.11
tough-cookie: 4.1.3
yauzl: 3.2.1
packageExtensionsChecksum: sha256-n+P/SQo4Pf+dHYpYn1Y6wL4cJEVoVzZ835N0OEp4TM8=
@ -4440,9 +4441,6 @@ packages:
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fd-slicer@1.1.0:
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -6805,8 +6803,9 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yauzl@2.10.0:
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
yauzl@3.2.1:
resolution: {integrity: sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==}
engines: {node: '>=12'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
@ -11574,7 +11573,7 @@ snapshots:
dependencies:
debug: 4.4.3
get-stream: 5.2.0
yauzl: 2.10.0
yauzl: 3.2.1
optionalDependencies:
'@types/yauzl': 2.10.3
transitivePeerDependencies:
@ -11606,10 +11605,6 @@ snapshots:
dependencies:
reusify: 1.1.0
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@ -14279,10 +14274,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yauzl@2.10.0:
yauzl@3.2.1:
dependencies:
buffer-crc32: 0.2.13
fd-slicer: 1.1.0
pend: 1.2.0
yoctocolors@2.1.2: {}

View File

@ -88,6 +88,11 @@ fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" \
-c "$A2UI_APP_DIR/rolldown.config.mjs"
else
pnpm -s dlx rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
fi

View File

@ -113,6 +113,41 @@ function resolveRoute(route) {
return { ok: routes.has(current), terminal: current };
}
/** @param {unknown} node */
function collectNavPageEntries(node) {
/** @type {string[]} */
const entries = [];
if (Array.isArray(node)) {
for (const item of node) {
entries.push(...collectNavPageEntries(item));
}
return entries;
}
if (!node || typeof node !== "object") {
return entries;
}
const record = /** @type {Record<string, unknown>} */ (node);
if (Array.isArray(record.pages)) {
for (const page of record.pages) {
if (typeof page === "string") {
entries.push(page);
} else {
entries.push(...collectNavPageEntries(page));
}
}
}
for (const value of Object.values(record)) {
if (value !== record.pages) {
entries.push(...collectNavPageEntries(value));
}
}
return entries;
}
const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
/** @type {{file: string; line: number; link: string; reason: string}[]} */
@ -221,6 +256,22 @@ for (const abs of markdownFiles) {
}
}
for (const page of collectNavPageEntries(docsConfig.navigation || [])) {
checked++;
const route = normalizeRoute(page);
const resolvedRoute = resolveRoute(route);
if (resolvedRoute.ok) {
continue;
}
broken.push({
file: "docs.json",
line: 0,
link: page,
reason: `navigation page not published (terminal: ${resolvedRoute.terminal})`,
});
}
console.log(`checked_internal_links=${checked}`);
console.log(`broken_links=${broken.length}`);

View File

@ -0,0 +1,44 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
import { writeConfigDocBaselineStatefile } from "../src/config/doc-baseline.js";
const args = new Set(process.argv.slice(2));
const checkOnly = args.has("--check");
if (checkOnly && args.has("--write")) {
console.error("Use either --check or --write, not both.");
process.exit(1);
}
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const result = await writeConfigDocBaselineStatefile({
repoRoot,
check: checkOnly,
});
if (checkOnly) {
if (!result.changed) {
console.log(
`OK ${path.relative(repoRoot, result.jsonPath)} ${path.relative(repoRoot, result.statefilePath)}`,
);
process.exit(0);
}
console.error(
[
"Config baseline drift detected.",
`Expected current: ${path.relative(repoRoot, result.jsonPath)}`,
`Expected current: ${path.relative(repoRoot, result.statefilePath)}`,
"If this config-surface change is intentional, run `pnpm config:docs:gen` and commit the updated baseline files.",
"If not intentional, treat this as docs drift or a possible breaking config change and fix the schema/help changes first.",
].join("\n"),
);
process.exit(1);
}
console.log(
[
`Wrote ${path.relative(repoRoot, result.jsonPath)}`,
`Wrote ${path.relative(repoRoot, result.statefilePath)}`,
].join("\n"),
);

View File

@ -51,7 +51,7 @@ function replaceBlockLines(
}
function renderKimiK2Ids(prefix: string) {
return MOONSHOT_KIMI_K2_MODELS.map((model) => `- \`${prefix}${model.id}\``);
return [...MOONSHOT_KIMI_K2_MODELS.map((model) => `- \`${prefix}${model.id}\``), ""];
}
function renderMoonshotAliases() {
@ -90,8 +90,8 @@ async function syncMoonshotDocs() {
let moonshotText = await readFile(moonshotDoc, "utf8");
moonshotText = replaceBlockLines(
moonshotText,
"{/_ moonshot-kimi-k2-ids:start _/ && null}",
"{/_ moonshot-kimi-k2-ids:end _/ && null}",
'[//]: # "moonshot-kimi-k2-ids:start"',
'[//]: # "moonshot-kimi-k2-ids:end"',
renderKimiK2Ids(""),
);
moonshotText = replaceBlockLines(
@ -110,8 +110,8 @@ async function syncMoonshotDocs() {
let conceptsText = await readFile(conceptsDoc, "utf8");
conceptsText = replaceBlockLines(
conceptsText,
"{/_ moonshot-kimi-k2-model-refs:start _/ && null}",
"{/_ moonshot-kimi-k2-model-refs:end _/ && null}",
'[//]: # "moonshot-kimi-k2-model-refs:start"',
'[//]: # "moonshot-kimi-k2-model-refs:end"',
renderKimiK2Ids("moonshot/"),
);

View File

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "./auth-profiles/types.js";
const mocks = vi.hoisted(() => ({
readCodexCliCredentialsCached: vi.fn(),
readQwenCliCredentialsCached: vi.fn(() => null),
readMiniMaxCliCredentialsCached: vi.fn(() => null),
}));
vi.mock("./cli-credentials.js", () => ({
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
readQwenCliCredentialsCached: mocks.readQwenCliCredentialsCached,
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
}));
const { syncExternalCliCredentials } = await import("./auth-profiles/external-cli-sync.js");
const { CODEX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js");
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
describe("syncExternalCliCredentials", () => {
it("syncs Codex CLI credentials into the supported default auth profile", () => {
const expires = Date.now() + 60_000;
mocks.readCodexCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires,
accountId: "acct_123",
});
const store: AuthProfileStore = {
version: 1,
profiles: {},
};
const mutated = syncExternalCliCredentials(store);
expect(mutated).toBe(true);
expect(mocks.readCodexCliCredentialsCached).toHaveBeenCalledWith(
expect.objectContaining({ ttlMs: expect.any(Number) }),
);
expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires,
accountId: "acct_123",
});
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
});
});

View File

@ -1,4 +1,5 @@
import {
readCodexCliCredentialsCached,
readQwenCliCredentialsCached,
readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js";
@ -11,6 +12,8 @@ import {
} from "./constants.js";
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) {
return false;
@ -37,7 +40,11 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
if (cred.type !== "oauth" && cred.type !== "token") {
return false;
}
if (cred.provider !== "qwen-portal" && cred.provider !== "minimax-portal") {
if (
cred.provider !== "qwen-portal" &&
cred.provider !== "minimax-portal" &&
cred.provider !== "openai-codex"
) {
return false;
}
if (typeof cred.expires !== "number") {
@ -82,7 +89,8 @@ function syncExternalCliCredentialsForProvider(
}
/**
* Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI) into the store.
* Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI, Codex CLI)
* into the store.
*
* Returns true if any credentials were updated.
*/
@ -130,6 +138,17 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
) {
mutated = true;
}
if (
syncExternalCliCredentialsForProvider(
store,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
"openai-codex",
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now,
)
) {
mutated = true;
}
return mutated;
}

View File

@ -212,16 +212,11 @@ describe("normalizeModelCompat", () => {
});
});
it("leaves supportsUsageInStreaming at default for generic custom openai-completions provider", () => {
const model = {
...baseModel(),
it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => {
expectSupportsUsageInStreamingForcedOff({
provider: "custom-cpa",
baseUrl: "https://cpa.example.com/v1",
};
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model as Model<Api>);
// supportsUsageInStreaming is no longer forced off — pi-ai's default (true) applies
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
});
});
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
@ -271,7 +266,7 @@ describe("normalizeModelCompat", () => {
expect(supportsUsageInStreaming(normalized)).toBe(true);
});
it("forces supportsDeveloperRole off but leaves supportsUsageInStreaming unset for non-native endpoints", () => {
it("still forces flags off when not explicitly set by user", () => {
const model = {
...baseModel(),
provider: "custom-cpa",
@ -280,8 +275,7 @@ describe("normalizeModelCompat", () => {
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false);
// supportsUsageInStreaming is no longer forced off — pi-ai default applies
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
expect(supportsUsageInStreaming(normalized)).toBe(false);
});
it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
@ -296,8 +290,7 @@ describe("normalizeModelCompat", () => {
expect(supportsDeveloperRole(model)).toBeUndefined();
expect(supportsUsageInStreaming(model)).toBeUndefined();
expect(supportsDeveloperRole(normalized)).toBe(false);
// supportsUsageInStreaming is not set by normalizeModelCompat — pi-ai default applies
expect(supportsUsageInStreaming(normalized)).toBeUndefined();
expect(supportsUsageInStreaming(normalized)).toBe(false);
});
it("does not override explicit compat false", () => {

View File

@ -52,16 +52,11 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
return model;
}
// The `developer` role is an OpenAI-native behavior that most compatible
// backends reject. Force it off for non-native endpoints unless the user
// has explicitly opted in via their model config.
//
// `supportsUsageInStreaming` is NOT forced off — most OpenAI-compatible
// backends (DashScope, DeepSeek, Groq, Together, etc.) handle
// `stream_options: { include_usage: true }` correctly, and disabling it
// silently breaks usage/cost tracking for all non-native providers.
// Users can still opt out with `compat.supportsUsageInStreaming: false`
// if their backend rejects the parameter.
// The `developer` role and stream usage chunks are OpenAI-native behaviors.
// Many OpenAI-compatible backends reject `developer` and/or emit usage-only
// chunks that break strict parsers expecting choices[0]. For non-native
// openai-completions endpoints, force both compat flags off — unless the
// user has explicitly opted in via their model config.
const compat = model.compat ?? undefined;
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so
// leave compat unchanged and let default native behavior apply.
@ -70,22 +65,24 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
return model;
}
// Respect explicit user overrides.
// Respect explicit user overrides: if the user has set a compat flag to
// true in their model definition, they know their endpoint supports it.
const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
if (forcedDeveloperRole) {
if (forcedDeveloperRole && forcedUsageStreaming) {
return model;
}
// Only force supportsDeveloperRole off. Leave supportsUsageInStreaming
// at whatever the user set or pi-ai's default (true).
// Return a new object — do not mutate the caller's model reference.
return {
...model,
compat: compat
? {
...compat,
supportsDeveloperRole: false,
supportsDeveloperRole: forcedDeveloperRole || false,
supportsUsageInStreaming: forcedUsageStreaming || false,
}
: { supportsDeveloperRole: false },
: { supportsDeveloperRole: false, supportsUsageInStreaming: false },
} as typeof model;
}

View File

@ -64,11 +64,11 @@ export function handleAutoCompactionEnd(
emitAgentEvent({
runId: ctx.params.runId,
stream: "compaction",
data: { phase: "end", willRetry },
data: { phase: "end", willRetry, completed: hasResult && !wasAborted },
});
void ctx.params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry },
data: { phase: "end", willRetry, completed: hasResult && !wasAborted },
});
// Run after_compaction plugin hook (fire-and-forget)

View File

@ -157,11 +157,9 @@ describe("createOpenClawCodingTools", () => {
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("mentions Chrome extension relay in browser tool description", () => {
it("mentions user browser profile in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/profile="user"/i);
expect(browser.description).toMatch(/profile="chrome-relay"/i);
});
it("keeps browser tool schema properties after normalization", () => {
const browser = defaultTools.find((tool) => tool.name === "browser");

View File

@ -74,7 +74,7 @@ function formatConsoleToolResult(result: {
}
function isChromeStaleTargetError(profile: string | undefined, err: unknown): boolean {
if (profile !== "chrome-relay" && profile !== "chrome") {
if (profile !== "chrome-relay" && profile !== "chrome" && profile !== "user") {
return false;
}
const msg = String(err);
@ -314,7 +314,7 @@ export async function executeActAction(params: {
})) as { tabs?: unknown[] }
).tabs ?? [])
: await browserTabs(baseUrl, { profile }).catch(() => []);
// Some Chrome relay targetIds can go stale between snapshots and actions.
// Some user-browser targetIds can go stale between snapshots and actions.
// Only retry safe read-only actions, and only when exactly one tab remains attached.
if (retryRequest && canRetryChromeActWithoutTargetId(request) && tabs.length === 1) {
try {
@ -334,13 +334,17 @@ export async function executeActAction(params: {
}
}
if (!tabs.length) {
// Extension relay profiles need the toolbar icon click; Chrome MCP just needs Chrome running.
const isRelayProfile = profile === "chrome-relay" || profile === "chrome";
throw new Error(
"No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.",
isRelayProfile
? "No Chrome tabs are attached via the OpenClaw Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry."
: `No Chrome tabs found for profile="${profile}". Make sure Chrome (v146+) is running and has open tabs, then retry.`,
{ cause: err },
);
}
throw new Error(
`Chrome tab not found (stale targetId?). Run action=tabs profile="chrome-relay" and use one of the returned targetIds.`,
`Chrome tab not found (stale targetId?). Run action=tabs profile="${profile}" and use one of the returned targetIds.`,
{ cause: err },
);
}

View File

@ -287,9 +287,9 @@ describe("browser tool snapshot maxChars", () => {
expect(opts?.mode).toBeUndefined();
});
it("defaults to host when using profile=chrome-relay (even in sandboxed sessions)", async () => {
it("defaults to host when using an explicit extension relay profile (even in sandboxed sessions)", async () => {
setResolvedBrowserProfiles({
"chrome-relay": {
relay: {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
color: "#0066CC",
@ -298,14 +298,14 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
await tool.execute?.("call-1", {
action: "snapshot",
profile: "chrome-relay",
profile: "relay",
snapshotFormat: "ai",
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome-relay",
profile: "relay",
}),
);
});
@ -366,12 +366,12 @@ describe("browser tool snapshot maxChars", () => {
it("lets the server choose snapshot format when the user does not request one", async () => {
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome-relay" });
await tool.execute?.("call-1", { action: "snapshot", profile: "user" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
expect.objectContaining({
profile: "chrome-relay",
profile: "user",
}),
);
const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as
@ -438,21 +438,17 @@ describe("browser tool snapshot maxChars", () => {
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
it("keeps chrome-relay profile on host when node proxy is available", async () => {
it("keeps user profile on host when node proxy is available", async () => {
mockSingleBrowserProxyNode();
setResolvedBrowserProfiles({
"chrome-relay": {
driver: "extension",
cdpUrl: "http://127.0.0.1:18792",
color: "#0066CC",
},
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
});
const tool = createBrowserTool();
await tool.execute?.("call-1", { action: "status", profile: "chrome-relay" });
await tool.execute?.("call-1", { action: "status", profile: "user" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ profile: "chrome-relay" }),
expect.objectContaining({ profile: "user" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
});
@ -745,7 +741,7 @@ describe("browser tool external content wrapping", () => {
describe("browser tool act stale target recovery", () => {
registerBrowserToolAfterEachReset();
it("retries safe chrome-relay act once without targetId when exactly one tab remains", async () => {
it("retries safe user-browser act once without targetId when exactly one tab remains", async () => {
browserActionsMocks.browserAct
.mockRejectedValueOnce(new Error("404: tab not found"))
.mockResolvedValueOnce({ ok: true });
@ -754,7 +750,7 @@ describe("browser tool act stale target recovery", () => {
const tool = createBrowserTool();
const result = await tool.execute?.("call-1", {
action: "act",
profile: "chrome-relay",
profile: "user",
request: {
kind: "hover",
targetId: "stale-tab",
@ -767,18 +763,18 @@ describe("browser tool act stale target recovery", () => {
1,
undefined,
expect.objectContaining({ targetId: "stale-tab", kind: "hover", ref: "btn-1" }),
expect.objectContaining({ profile: "chrome-relay" }),
expect.objectContaining({ profile: "user" }),
);
expect(browserActionsMocks.browserAct).toHaveBeenNthCalledWith(
2,
undefined,
expect.not.objectContaining({ targetId: expect.anything() }),
expect.objectContaining({ profile: "chrome-relay" }),
expect.objectContaining({ profile: "user" }),
);
expect(result?.details).toMatchObject({ ok: true });
});
it("does not retry mutating chrome-relay act requests without targetId", async () => {
it("does not retry mutating user-browser act requests without targetId", async () => {
browserActionsMocks.browserAct.mockRejectedValueOnce(new Error("404: tab not found"));
browserClientMocks.browserTabs.mockResolvedValueOnce([{ targetId: "only-tab" }]);
@ -786,14 +782,14 @@ describe("browser tool act stale target recovery", () => {
await expect(
tool.execute?.("call-1", {
action: "act",
profile: "chrome-relay",
profile: "user",
request: {
kind: "click",
targetId: "stale-tab",
ref: "btn-1",
},
}),
).rejects.toThrow(/Run action=tabs profile="chrome-relay"/i);
).rejects.toThrow(/Run action=tabs profile="user"/i);
expect(browserActionsMocks.browserAct).toHaveBeenCalledTimes(1);
});

View File

@ -293,10 +293,6 @@ function shouldPreferHostForProfile(profileName: string | undefined) {
return capabilities.requiresRelay || capabilities.usesChromeMcp;
}
function isHostOnlyProfileName(profileName: string | undefined) {
return profileName === "user" || profileName === "chrome-relay";
}
export function createBrowserTool(opts?: {
sandboxBridgeUrl?: string;
allowHostControl?: boolean;
@ -311,11 +307,8 @@ export function createBrowserTool(opts?: {
description: [
"Control the browser via OpenClaw's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
"Browser choice: omit profile by default for the isolated OpenClaw-managed browser (`openclaw`).",
'For the logged-in user browser on the local host, prefer profile="user". Use it only when existing logins/cookies matter and the user is present to click/approve any browser attach prompt.',
'Use profile="chrome-relay" only for the Chrome extension / Browser Relay / toolbar-button attach-tab flow, or when the user explicitly asks for the extension relay.',
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS prefer profile="chrome-relay". Otherwise prefer profile="user" over the extension relay for user-browser work.',
'For the logged-in user browser on the local host, use profile="user". Chrome (v146+) must be running. Use only when existing logins/cookies matter and the user is present.',
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
'User-browser flows need user interaction: profile="user" may require approving a browser attach prompt; profile="chrome-relay" needs the user to click the OpenClaw Browser Relay toolbar icon on the tab (badge ON). If user presence is unclear, ask first.',
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
@ -333,7 +326,9 @@ export function createBrowserTool(opts?: {
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
}
if (isHostOnlyProfileName(profile)) {
// User-browser profiles (existing-session, extension relay) are host-only.
const isUserBrowserProfile = shouldPreferHostForProfile(profile);
if (isUserBrowserProfile) {
if (requestedNode || target === "node") {
throw new Error(`profile="${profile}" only supports the local host browser.`);
}
@ -342,10 +337,9 @@ export function createBrowserTool(opts?: {
`profile="${profile}" cannot use the sandbox browser; use target="host" or omit target.`,
);
}
}
if (!target && !requestedNode && shouldPreferHostForProfile(profile)) {
// Local host user-browser profiles should not silently bind to sandbox/node browsers.
target = "host";
if (!target && !requestedNode) {
target = "host";
}
}
const nodeTarget = await resolveBrowserNodeTarget({

View File

@ -67,7 +67,7 @@ export type AgentRunLoopResult =
fallbackModel?: string;
fallbackAttempts: RuntimeFallbackAttempt[];
didLogHeartbeatStrip: boolean;
autoCompactionCompleted: boolean;
autoCompactionCount: number;
/** Payload keys sent directly (not via pipeline) during tool flush. */
directlySentBlockKeys?: Set<string>;
}
@ -103,7 +103,7 @@ export async function runAgentTurnWithFallback(params: {
}): Promise<AgentRunLoopResult> {
const TRANSIENT_HTTP_RETRY_DELAY_MS = 2_500;
let didLogHeartbeatStrip = false;
let autoCompactionCompleted = false;
let autoCompactionCount = 0;
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
const directlySentBlockKeys = new Set<string>();
@ -319,154 +319,165 @@ export async function runAgentTurnWithFallback(params: {
},
);
return (async () => {
const result = await runEmbeddedPiAgent({
...embeddedContext,
trigger: params.isHeartbeat ? "heartbeat" : "user",
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
...senderContext,
...runBaseParams,
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
params.sessionCtx.Provider,
);
if (!channel) {
return "markdown";
}
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings,
bootstrapContextMode: params.opts?.bootstrapContextMode,
bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default",
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (!params.opts?.onPartialReply || textForTyping === undefined) {
return;
}
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
});
},
onAssistantMessageStart: async () => {
await params.typingSignals.signalMessageStart();
await params.opts?.onAssistantMessageStart?.();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
}
: undefined,
onReasoningEnd: params.opts?.onReasoningEnd,
onAgentEvent: async (evt) => {
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data.phase === "string";
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
notifyAgentRunStart();
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const name = typeof evt.data.name === "string" ? evt.data.name : undefined;
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
await params.opts?.onToolStart?.({ name, phase });
let attemptCompactionCount = 0;
try {
const result = await runEmbeddedPiAgent({
...embeddedContext,
trigger: params.isHeartbeat ? "heartbeat" : "user",
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
...senderContext,
...runBaseParams,
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
params.sessionCtx.Provider,
);
if (!channel) {
return "markdown";
}
}
// Track auto-compaction completion and notify UI layer
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start") {
await params.opts?.onCompactionStart?.();
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings,
bootstrapContextMode: params.opts?.bootstrapContextMode,
bootstrapContextRunKind: params.opts?.isHeartbeat ? "heartbeat" : "default",
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (!params.opts?.onPartialReply || textForTyping === undefined) {
return;
}
if (phase === "end") {
autoCompactionCompleted = true;
await params.opts?.onCompactionEnd?.();
}
}
},
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply: params.opts?.onBlockReply
? createBlockReplyDeliveryHandler({
onBlockReply: params.opts.onBlockReply,
currentMessageId:
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid,
normalizeStreamingText,
applyReplyToMode: params.applyReplyToMode,
normalizeMediaPaths: normalizeReplyMediaPaths,
typingSignals: params.typingSignals,
blockStreamingEnabled: params.blockStreamingEnabled,
blockReplyPipeline,
directlySentBlockKeys,
})
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
? async () => {
await blockReplyPipeline.flush({ force: true });
}
: undefined,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[
bootstrapPromptWarningSignaturesSeen.length - 1
],
onToolResult: onToolResult
? (() => {
// Serialize tool result delivery to preserve message ordering.
// Without this, concurrent tool callbacks race through typing signals
// and message sends, causing out-of-order delivery to the user.
// See: https://github.com/openclaw/openclaw/issues/11044
let toolResultChain: Promise<void> = Promise.resolve();
return (payload: ReplyPayload) => {
toolResultChain = toolResultChain
.then(async () => {
const { text, skip } = normalizeStreamingText(payload);
if (skip) {
return;
}
await params.typingSignals.signalTextDelta(text);
await onToolResult({
...payload,
text,
});
})
.catch((err) => {
// Keep chain healthy after an error so later tool results still deliver.
logVerbose(`tool result delivery failed: ${String(err)}`);
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
});
},
onAssistantMessageStart: async () => {
await params.typingSignals.signalMessageStart();
await params.opts?.onAssistantMessageStart?.();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
const task = toolResultChain.finally(() => {
params.pendingToolTasks.delete(task);
});
params.pendingToolTasks.add(task);
};
})()
: undefined,
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
return result;
}
: undefined,
onReasoningEnd: params.opts?.onReasoningEnd,
onAgentEvent: async (evt) => {
// Signal run start only after the embedded agent emits real activity.
const hasLifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data.phase === "string";
if (evt.stream !== "lifecycle" || hasLifecyclePhase) {
notifyAgentRunStart();
}
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const name = typeof evt.data.name === "string" ? evt.data.name : undefined;
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
await params.opts?.onToolStart?.({ name, phase });
}
}
// Track auto-compaction completion and notify UI layer.
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start") {
await params.opts?.onCompactionStart?.();
}
const completed = evt.data?.completed === true;
if (phase === "end" && completed) {
attemptCompactionCount += 1;
await params.opts?.onCompactionEnd?.();
}
}
},
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply: params.opts?.onBlockReply
? createBlockReplyDeliveryHandler({
onBlockReply: params.opts.onBlockReply,
currentMessageId:
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid,
normalizeStreamingText,
applyReplyToMode: params.applyReplyToMode,
normalizeMediaPaths: normalizeReplyMediaPaths,
typingSignals: params.typingSignals,
blockStreamingEnabled: params.blockStreamingEnabled,
blockReplyPipeline,
directlySentBlockKeys,
})
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
? async () => {
await blockReplyPipeline.flush({ force: true });
}
: undefined,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[
bootstrapPromptWarningSignaturesSeen.length - 1
],
onToolResult: onToolResult
? (() => {
// Serialize tool result delivery to preserve message ordering.
// Without this, concurrent tool callbacks race through typing signals
// and message sends, causing out-of-order delivery to the user.
// See: https://github.com/openclaw/openclaw/issues/11044
let toolResultChain: Promise<void> = Promise.resolve();
return (payload: ReplyPayload) => {
toolResultChain = toolResultChain
.then(async () => {
const { text, skip } = normalizeStreamingText(payload);
if (skip) {
return;
}
await params.typingSignals.signalTextDelta(text);
await onToolResult({
...payload,
text,
});
})
.catch((err) => {
// Keep chain healthy after an error so later tool results still deliver.
logVerbose(`tool result delivery failed: ${String(err)}`);
});
const task = toolResultChain.finally(() => {
params.pendingToolTasks.delete(task);
});
params.pendingToolTasks.add(task);
};
})()
: undefined,
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
const resultCompactionCount = Math.max(
0,
result.meta?.agentMeta?.compactionCount ?? 0,
);
attemptCompactionCount = Math.max(attemptCompactionCount, resultCompactionCount);
return result;
} finally {
autoCompactionCount += attemptCompactionCount;
}
})();
},
});
@ -654,7 +665,7 @@ export async function runAgentTurnWithFallback(params: {
fallbackModel,
fallbackAttempts,
didLogHeartbeatStrip,
autoCompactionCompleted,
autoCompactionCount,
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
};
}

View File

@ -322,7 +322,7 @@ describe("runReplyAgent auto-compaction token update", () => {
extraSystemPrompt?: string;
onAgentEvent?: (evt: {
stream?: string;
data?: { phase?: string; willRetry?: boolean };
data?: { phase?: string; willRetry?: boolean; completed?: boolean };
}) => void;
};
@ -397,7 +397,10 @@ describe("runReplyAgent auto-compaction token update", () => {
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
// Simulate auto-compaction during agent run
params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } });
params.onAgentEvent?.({ stream: "compaction", data: { phase: "end", willRetry: false } });
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false, completed: true },
});
return {
payloads: [{ text: "done" }],
meta: {
@ -455,6 +458,238 @@ describe("runReplyAgent auto-compaction token update", () => {
expect(stored[sessionKey].compactionCount).toBe(1);
});
it("tracks auto-compaction from embedded result metadata even when no compaction event is emitted", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-meta-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 181_000,
compactionCount: 0,
};
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "done" }],
meta: {
agentMeta: {
usage: { input: 190_000, output: 8_000, total: 198_000 },
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
compactionCount: 2,
},
},
});
const config = {
agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } },
};
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
sessionEntry,
config,
});
await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-5",
agentCfgContextTokens: 200_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBe(10_000);
expect(stored[sessionKey].compactionCount).toBe(2);
});
it("accumulates compactions across fallback attempts without double-counting a single attempt", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-fallback-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 181_000,
compactionCount: 0,
};
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
runWithModelFallbackMock.mockImplementationOnce(async ({ run }: RunWithModelFallbackParams) => {
try {
await run("anthropic", "claude");
} catch {
// Expected first-attempt failure.
}
return {
result: await run("openai", "gpt-5.2"),
provider: "openai",
model: "gpt-5.2",
attempts: [{ provider: "anthropic", model: "claude", error: "attempt failed" }],
};
});
runEmbeddedPiAgentMock
.mockImplementationOnce(async (params: EmbeddedRunParams) => {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: true, completed: true },
});
throw new Error("attempt failed");
})
.mockResolvedValueOnce({
payloads: [{ text: "done" }],
meta: {
agentMeta: {
usage: { input: 190_000, output: 8_000, total: 198_000 },
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
compactionCount: 2,
},
},
});
const config = {
agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } },
};
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
sessionEntry,
config,
});
await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-5",
agentCfgContextTokens: 200_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBe(10_000);
expect(stored[sessionKey].compactionCount).toBe(3);
});
it("does not count failed compaction end events from earlier fallback attempts", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-fallback-failed-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
const sessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 181_000,
compactionCount: 0,
};
await seedSessionStore({ storePath, sessionKey, entry: sessionEntry });
runWithModelFallbackMock.mockImplementationOnce(async ({ run }: RunWithModelFallbackParams) => {
try {
await run("anthropic", "claude");
} catch {
// Expected first-attempt failure.
}
return {
result: await run("openai", "gpt-5.2"),
provider: "openai",
model: "gpt-5.2",
attempts: [{ provider: "anthropic", model: "claude", error: "attempt failed" }],
};
});
runEmbeddedPiAgentMock
.mockImplementationOnce(async (params: EmbeddedRunParams) => {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: true, completed: false },
});
throw new Error("attempt failed");
})
.mockResolvedValueOnce({
payloads: [{ text: "done" }],
meta: {
agentMeta: {
usage: { input: 190_000, output: 8_000, total: 198_000 },
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
compactionCount: 2,
},
},
});
const config = {
agents: { defaults: { compaction: { memoryFlush: { enabled: false } } } },
};
const { typing, sessionCtx, resolvedQueue, followupRun } = createBaseRun({
storePath,
sessionEntry,
config,
});
await runReplyAgent({
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
typing,
sessionCtx,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
defaultModel: "anthropic/claude-opus-4-5",
agentCfgContextTokens: 200_000,
resolvedVerboseLevel: "off",
isNewSession: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
shouldInjectGroupIntro: false,
typingMode: "instant",
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBe(10_000);
expect(stored[sessionKey].compactionCount).toBe(2);
});
it("updates totalTokens from lastCallUsage even without compaction", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-last-"));
const storePath = path.join(tmp, "sessions.json");
@ -537,7 +772,10 @@ describe("runReplyAgent auto-compaction token update", () => {
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedRunParams) => {
params.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } });
params.onAgentEvent?.({ stream: "compaction", data: { phase: "end", willRetry: false } });
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false, completed: true },
});
return {
payloads: [{ text: "done" }],
meta: {

View File

@ -380,7 +380,7 @@ export async function runReplyAgent(params: {
fallbackAttempts,
directlySentBlockKeys,
} = runOutcome;
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
let { didLogHeartbeatStrip, autoCompactionCount } = runOutcome;
if (
shouldInjectGroupIntro &&
@ -664,12 +664,13 @@ export async function runReplyAgent(params: {
}
}
if (autoCompactionCompleted) {
if (autoCompactionCount > 0) {
const count = await incrementRunCompactionCount({
sessionEntry: activeSessionEntry,
sessionStore: activeSessionStore,
sessionKey,
storePath,
amount: autoCompactionCount,
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
contextTokensUsed,
});

View File

@ -71,7 +71,7 @@ function mockCompactionRun(params: {
}) => {
args.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: params.willRetry },
data: { phase: "end", willRetry: params.willRetry, completed: true },
});
return params.result;
},
@ -126,6 +126,110 @@ describe("createFollowupRunner compaction", () => {
expect(firstCall?.[0]?.text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(1);
});
it("tracks auto-compaction from embedded result metadata even when no compaction event is emitted", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-meta-")),
"sessions.json",
);
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = {
main: sessionEntry,
};
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "final" }],
meta: {
agentMeta: {
compactionCount: 2,
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
},
},
});
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
const queued = createQueuedRun({
run: {
verboseLevel: "on",
},
});
await runner(queued);
expect(onBlockReply).toHaveBeenCalled();
const firstCall = (onBlockReply.mock.calls as unknown as Array<Array<{ text?: string }>>)[0];
expect(firstCall?.[0]?.text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(2);
});
it("does not count failed compaction end events in followup runs", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-failed-")),
"sessions.json",
);
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = {
main: sessionEntry,
};
const onBlockReply = vi.fn(async () => {});
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
const queued = createQueuedRun({
run: {
verboseLevel: "on",
},
});
runEmbeddedPiAgentMock.mockImplementationOnce(async (args) => {
args.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false, completed: false },
});
return {
payloads: [{ text: "final" }],
meta: {
agentMeta: {
compactionCount: 0,
lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 },
},
},
};
});
await runner(queued);
expect(onBlockReply).toHaveBeenCalledTimes(1);
const firstCall = (onBlockReply.mock.calls as unknown as Array<Array<{ text?: string }>>)[0];
expect(firstCall?.[0]?.text).toBe("final");
expect(sessionStore.main.compactionCount).toBeUndefined();
});
});
describe("createFollowupRunner bootstrap warning dedupe", () => {

View File

@ -145,7 +145,7 @@ export function createFollowupRunner(params: {
isControlUiVisible: shouldSurfaceToControlUi,
});
}
let autoCompactionCompleted = false;
let autoCompactionCount = 0;
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = queued.run.provider;
let fallbackModel = queued.run.model;
@ -168,68 +168,81 @@ export function createFollowupRunner(params: {
}),
run: async (provider, model, runOptions) => {
const authProfile = resolveRunAuthProfile(queued.run, provider);
const result = await runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
agentId: queued.run.agentId,
trigger: "user",
messageChannel: queued.originatingChannel ?? undefined,
messageProvider: queued.run.messageProvider,
agentAccountId: queued.run.agentAccountId,
messageTo: queued.originatingTo,
messageThreadId: queued.originatingThreadId,
currentChannelId: queued.originatingTo,
currentThreadTs:
queued.originatingThreadId != null ? String(queued.originatingThreadId) : undefined,
groupId: queued.run.groupId,
groupChannel: queued.run.groupChannel,
groupSpace: queued.run.groupSpace,
senderId: queued.run.senderId,
senderName: queued.run.senderName,
senderUsername: queued.run.senderUsername,
senderE164: queued.run.senderE164,
senderIsOwner: queued.run.senderIsOwner,
sessionFile: queued.run.sessionFile,
agentDir: queued.run.agentDir,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider,
model,
...authProfile,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
reasoningLevel: queued.run.reasoningLevel,
suppressToolErrorWarnings: opts?.suppressToolErrorWarnings,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
blockReplyBreak: queued.run.blockReplyBreak,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[
bootstrapPromptWarningSignaturesSeen.length - 1
],
onAgentEvent: (evt) => {
if (evt.stream !== "compaction") {
return;
}
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "end") {
autoCompactionCompleted = true;
}
},
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
return result;
let attemptCompactionCount = 0;
try {
const result = await runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
agentId: queued.run.agentId,
trigger: "user",
messageChannel: queued.originatingChannel ?? undefined,
messageProvider: queued.run.messageProvider,
agentAccountId: queued.run.agentAccountId,
messageTo: queued.originatingTo,
messageThreadId: queued.originatingThreadId,
currentChannelId: queued.originatingTo,
currentThreadTs:
queued.originatingThreadId != null
? String(queued.originatingThreadId)
: undefined,
groupId: queued.run.groupId,
groupChannel: queued.run.groupChannel,
groupSpace: queued.run.groupSpace,
senderId: queued.run.senderId,
senderName: queued.run.senderName,
senderUsername: queued.run.senderUsername,
senderE164: queued.run.senderE164,
senderIsOwner: queued.run.senderIsOwner,
sessionFile: queued.run.sessionFile,
agentDir: queued.run.agentDir,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider,
model,
...authProfile,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
reasoningLevel: queued.run.reasoningLevel,
suppressToolErrorWarnings: opts?.suppressToolErrorWarnings,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
blockReplyBreak: queued.run.blockReplyBreak,
bootstrapPromptWarningSignaturesSeen,
bootstrapPromptWarningSignature:
bootstrapPromptWarningSignaturesSeen[
bootstrapPromptWarningSignaturesSeen.length - 1
],
onAgentEvent: (evt) => {
if (evt.stream !== "compaction") {
return;
}
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const completed = evt.data?.completed === true;
if (phase === "end" && completed) {
attemptCompactionCount += 1;
}
},
});
bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
result.meta?.systemPromptReport,
);
const resultCompactionCount = Math.max(
0,
result.meta?.agentMeta?.compactionCount ?? 0,
);
attemptCompactionCount = Math.max(attemptCompactionCount, resultCompactionCount);
return result;
} finally {
autoCompactionCount += attemptCompactionCount;
}
},
});
runResult = fallbackResult.result;
@ -326,12 +339,13 @@ export function createFollowupRunner(params: {
return;
}
if (autoCompactionCompleted) {
if (autoCompactionCount > 0) {
const count = await incrementRunCompactionCount({
sessionEntry,
sessionStore,
sessionKey,
storePath,
amount: autoCompactionCount,
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
contextTokensUsed,
});

View File

@ -445,6 +445,23 @@ describe("incrementCompactionCount", () => {
expect(stored[sessionKey].outputTokens).toBeUndefined();
});
it("increments compaction count by an explicit amount", async () => {
const entry = { sessionId: "s1", updatedAt: Date.now(), compactionCount: 2 } as SessionEntry;
const { storePath, sessionKey, sessionStore } = await createCompactionSessionFixture(entry);
const count = await incrementCompactionCount({
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
amount: 2,
});
expect(count).toBe(4);
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].compactionCount).toBe(4);
});
it("does not update totalTokens when tokensAfter is not provided", async () => {
const entry = {
sessionId: "s1",

View File

@ -8,6 +8,7 @@ type IncrementRunCompactionCountParams = Omit<
Parameters<typeof incrementCompactionCount>[0],
"tokensAfter"
> & {
amount?: number;
lastCallUsage?: NormalizedUsage;
contextTokensUsed?: number;
};
@ -30,6 +31,7 @@ export async function incrementRunCompactionCount(
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
amount: params.amount,
tokensAfter: tokensAfterCompaction,
});
}

View File

@ -255,6 +255,7 @@ export async function incrementCompactionCount(params: {
sessionKey?: string;
storePath?: string;
now?: number;
amount?: number;
/** Token count after compaction - if provided, updates session token counts */
tokensAfter?: number;
}): Promise<number | undefined> {
@ -264,6 +265,7 @@ export async function incrementCompactionCount(params: {
sessionKey,
storePath,
now = Date.now(),
amount = 1,
tokensAfter,
} = params;
if (!sessionStore || !sessionKey) {
@ -273,7 +275,8 @@ export async function incrementCompactionCount(params: {
if (!entry) {
return undefined;
}
const nextCount = (entry.compactionCount ?? 0) + 1;
const incrementBy = Math.max(0, amount);
const nextCount = (entry.compactionCount ?? 0) + incrementBy;
// Build update payload with compaction count and optionally updated token counts
const updates: Partial<SessionEntry> = {
compactionCount: nextCount,

View File

@ -266,11 +266,6 @@ describe("browser server-context listKnownProfileNames", () => {
]),
};
expect(listKnownProfileNames(state).toSorted()).toEqual([
"chrome-relay",
"openclaw",
"stale-removed",
"user",
]);
expect(listKnownProfileNames(state).toSorted()).toEqual(["openclaw", "stale-removed", "user"]);
});
});

View File

@ -193,7 +193,7 @@ async function createRealSession(profileName: string): Promise<ChromeMcpSession>
await client.close().catch(() => {});
throw new BrowserProfileUnavailableError(
`Chrome MCP existing-session attach failed for profile "${profileName}". ` +
`Make sure Chrome is running, enable chrome://inspect/#remote-debugging, and approve the connection. ` +
`Make sure Chrome (v146+) is running. ` +
`Details: ${String(err)}`,
);
}

View File

@ -26,10 +26,8 @@ describe("browser config", () => {
expect(user?.driver).toBe("existing-session");
expect(user?.cdpPort).toBe(0);
expect(user?.cdpUrl).toBe("");
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(18792);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:18792");
// chrome-relay is no longer auto-created
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
@ -38,10 +36,7 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.controlPort).toBe(19003);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(19004);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19004");
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19012);
@ -53,10 +48,7 @@ describe("browser config", () => {
withEnv({ OPENCLAW_GATEWAY_PORT: undefined }, () => {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013);
const chromeRelay = resolveProfile(resolved, "chrome-relay");
expect(chromeRelay?.driver).toBe("extension");
expect(chromeRelay?.cdpPort).toBe(19014);
expect(chromeRelay?.cdpUrl).toBe("http://127.0.0.1:19014");
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.cdpPort).toBe(19022);
@ -209,16 +201,6 @@ describe("browser config", () => {
);
});
it("does not add the built-in chrome-relay profile if the derived relay port is already used", () => {
const resolved = resolveBrowserConfig({
profiles: {
openclaw: { cdpPort: 18792, color: "#FF4500" },
},
});
expect(resolveProfile(resolved, "chrome-relay")).toBe(null);
expect(resolved.defaultProfile).toBe("openclaw");
});
it("defaults extraArgs to empty array when not provided", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.extraArgs).toEqual([]);
@ -307,6 +289,7 @@ describe("browser config", () => {
const resolved = resolveBrowserConfig({
profiles: {
"chrome-live": { driver: "existing-session", attachOnly: true, color: "#00AA00" },
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
work: { cdpPort: 18801, color: "#0066CC" },
},
});
@ -317,7 +300,7 @@ describe("browser config", () => {
const managed = resolveProfile(resolved, "openclaw")!;
expect(getBrowserProfileCapabilities(managed).usesChromeMcp).toBe(false);
const extension = resolveProfile(resolved, "chrome-relay")!;
const extension = resolveProfile(resolved, "relay")!;
expect(getBrowserProfileCapabilities(extension).usesChromeMcp).toBe(false);
const work = resolveProfile(resolved, "work")!;
@ -358,17 +341,17 @@ describe("browser config", () => {
it("explicit defaultProfile config overrides defaults in headless mode", () => {
const resolved = resolveBrowserConfig({
headless: true,
defaultProfile: "chrome-relay",
defaultProfile: "user",
});
expect(resolved.defaultProfile).toBe("chrome-relay");
expect(resolved.defaultProfile).toBe("user");
});
it("explicit defaultProfile config overrides defaults in noSandbox mode", () => {
const resolved = resolveBrowserConfig({
noSandbox: true,
defaultProfile: "chrome-relay",
defaultProfile: "user",
});
expect(resolved.defaultProfile).toBe("chrome-relay");
expect(resolved.defaultProfile).toBe("user");
});
it("allows custom profile as default even in headless mode", () => {

View File

@ -14,7 +14,7 @@ import {
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
} from "./constants.js";
import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
import { CDP_PORT_RANGE_START } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
@ -197,36 +197,6 @@ function ensureDefaultUserBrowserProfile(
return result;
}
/**
* Ensure a built-in "chrome-relay" profile exists for the Chrome extension relay.
*
* Note: this is an OpenClaw browser profile (routing config), not a Chrome user profile.
* It points at the local relay CDP endpoint (controlPort + 1).
*/
function ensureDefaultChromeRelayProfile(
profiles: Record<string, BrowserProfileConfig>,
controlPort: number,
): Record<string, BrowserProfileConfig> {
const result = { ...profiles };
if (result["chrome-relay"]) {
return result;
}
const relayPort = controlPort + 1;
if (!Number.isFinite(relayPort) || relayPort <= 0 || relayPort > 65535) {
return result;
}
// Avoid adding the built-in profile if the derived relay port is already used by another profile
// (legacy single-profile configs may use controlPort+1 for openclaw/openclaw CDP).
if (getUsedPorts(result).has(relayPort)) {
return result;
}
result["chrome-relay"] = {
driver: "extension",
cdpUrl: `http://127.0.0.1:${relayPort}`,
color: "#00AA00",
};
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: OpenClawConfig,
@ -286,17 +256,14 @@ export function resolveBrowserConfig(
const legacyCdpPort = rawCdpUrl ? cdpInfo.port : undefined;
const isWsUrl = cdpInfo.parsed.protocol === "ws:" || cdpInfo.parsed.protocol === "wss:";
const legacyCdpUrl = rawCdpUrl && isWsUrl ? cdpInfo.normalized : undefined;
const profiles = ensureDefaultChromeRelayProfile(
ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
const profiles = ensureDefaultUserBrowserProfile(
ensureDefaultProfile(
cfg?.profiles,
defaultColor,
legacyCdpPort,
cdpPortRangeStart,
legacyCdpUrl,
),
controlPort,
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";

View File

@ -3,10 +3,15 @@ import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults chrome-relay snapshots to aria when format is omitted", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "chrome-relay");
it("defaults extension relay snapshots to aria when format is omitted", () => {
const resolved = resolveBrowserConfig({
profiles: {
relay: { driver: "extension", cdpUrl: "http://127.0.0.1:18792", color: "#0066CC" },
},
});
const profile = resolveProfile(resolved, "relay");
expect(profile).toBeTruthy();
expect(profile?.driver).toBe("extension");
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,

View File

@ -313,6 +313,7 @@ describe("canvas host", () => {
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
let createdLink = false;
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>> | undefined;
try {
await fs.stat(bundlePath);
@ -324,17 +325,16 @@ describe("canvas host", () => {
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
createdLink = true;
let server: Awaited<ReturnType<typeof startFixtureCanvasHost>>;
try {
server = await startFixtureCanvasHost(dir);
} catch (error) {
if (isLoopbackBindDenied(error)) {
return;
try {
server = await startFixtureCanvasHost(dir);
} catch (error) {
if (isLoopbackBindDenied(error)) {
return;
}
throw error;
}
throw error;
}
try {
const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`);
const html = await res.text();
expect(res.status).toBe(200);
@ -356,7 +356,7 @@ describe("canvas host", () => {
expect(symlinkRes.status).toBe(404);
expect(await symlinkRes.text()).toBe("not found");
} finally {
await server.close();
await server?.close();
if (createdLink) {
await fs.rm(linkPath, { force: true });
}

View File

@ -63,6 +63,13 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
refresh: "token-r2",
expires: Date.now() + 60_000,
},
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "token-c",
refresh: "token-r3",
expires: Date.now() + 60_000,
},
},
},
null,
@ -76,10 +83,11 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
profiles: {
"anthropic:claude-cli": { provider: "anthropic", mode: "oauth" },
"openai-codex:codex-cli": { provider: "openai-codex", mode: "oauth" },
"openai-codex:default": { provider: "openai-codex", mode: "oauth" },
},
order: {
anthropic: ["anthropic:claude-cli"],
"openai-codex": ["openai-codex:codex-cli"],
"openai-codex": ["openai-codex:codex-cli", "openai-codex:default"],
},
},
} as const;
@ -94,10 +102,12 @@ describe("maybeRemoveDeprecatedCliAuthProfiles", () => {
};
expect(raw.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(raw.profiles?.["openai-codex:codex-cli"]).toBeUndefined();
expect(raw.profiles?.["openai-codex:default"]).toBeDefined();
expect(next.auth?.profiles?.["anthropic:claude-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["openai-codex:codex-cli"]).toBeUndefined();
expect(next.auth?.profiles?.["openai-codex:default"]).toBeDefined();
expect(next.auth?.order?.anthropic).toBeUndefined();
expect(next.auth?.order?.["openai-codex"]).toBeUndefined();
expect(next.auth?.order?.["openai-codex"]).toEqual(["openai-codex:default"]);
});
});

View File

@ -126,6 +126,7 @@ export function applyZaiProviderConfig(
const defaultModels = [
buildZaiModelDefinition({ id: "glm-5" }),
buildZaiModelDefinition({ id: "glm-5-turbo" }),
buildZaiModelDefinition({ id: "glm-4.7" }),
buildZaiModelDefinition({ id: "glm-4.7-flash" }),
buildZaiModelDefinition({ id: "glm-4.7-flashx" }),

View File

@ -97,6 +97,7 @@ type MinimaxCatalogId = keyof typeof MINIMAX_MODEL_CATALOG;
const ZAI_MODEL_CATALOG = {
"glm-5": { name: "GLM-5", reasoning: true },
"glm-5-turbo": { name: "GLM-5 Turbo", reasoning: true },
"glm-4.7": { name: "GLM-4.7", reasoning: true },
"glm-4.7-flash": { name: "GLM-4.7 Flash", reasoning: true },
"glm-4.7-flashx": { name: "GLM-4.7 FlashX", reasoning: true },

View File

@ -473,6 +473,7 @@ describe("applyZaiConfig", () => {
});
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);
expect(ids).toContain("glm-5");
expect(ids).toContain("glm-5-turbo");
expect(ids).toContain("glm-4.7");
expect(ids).toContain("glm-4.7-flash");
expect(ids).toContain("glm-4.7-flashx");

View File

@ -212,6 +212,49 @@ describe("gateway.channelHealthCheckMinutes", () => {
expect(res.issues[0]?.path).toBe("gateway.channelHealthCheckMinutes");
}
});
it("rejects stale thresholds shorter than the health check interval", () => {
const res = validateConfigObject({
gateway: {
channelHealthCheckMinutes: 5,
channelStaleEventThresholdMinutes: 4,
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.channelStaleEventThresholdMinutes");
}
});
it("accepts stale thresholds that match or exceed the health check interval", () => {
const equal = validateConfigObject({
gateway: {
channelHealthCheckMinutes: 5,
channelStaleEventThresholdMinutes: 5,
},
});
expect(equal.ok).toBe(true);
const greater = validateConfigObject({
gateway: {
channelHealthCheckMinutes: 5,
channelStaleEventThresholdMinutes: 6,
},
});
expect(greater.ok).toBe(true);
});
it("rejects stale thresholds shorter than the default health check interval", () => {
const res = validateConfigObject({
gateway: {
channelStaleEventThresholdMinutes: 4,
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.channelStaleEventThresholdMinutes");
}
});
});
describe("cron webhook schema", () => {

View File

@ -111,9 +111,12 @@ describe("Nix integration (U3, U5, U9)", () => {
});
it("CONFIG_PATH uses STATE_DIR when only state dir is overridden", () => {
expect(resolveConfigPathCandidate(envWith({ OPENCLAW_STATE_DIR: "/custom/state" }))).toBe(
path.join(path.resolve("/custom/state"), "openclaw.json"),
);
expect(
resolveConfigPathCandidate(
envWith({ OPENCLAW_STATE_DIR: "/custom/state", OPENCLAW_TEST_FAST: "1" }),
() => path.join(path.sep, "tmp", "openclaw-config-home"),
),
).toBe(path.join(path.resolve("/custom/state"), "openclaw.json"));
});
});

View File

@ -44,7 +44,6 @@ async function writePluginFixture(params: {
}
describe("config plugin validation", () => {
const previousUmask = process.umask(0o022);
let fixtureRoot = "";
let suiteHome = "";
let badPluginDir = "";
@ -136,7 +135,6 @@ describe("config plugin validation", () => {
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
clearPluginManifestRegistryCache();
process.umask(previousUmask);
});
it("reports missing plugin refs across load paths, entries, and allowlist surfaces", async () => {

View File

@ -0,0 +1,160 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
buildConfigDocBaseline,
collectConfigDocBaselineEntries,
dedupeConfigDocBaselineEntries,
normalizeConfigDocBaselineHelpPath,
renderConfigDocBaselineStatefile,
writeConfigDocBaselineStatefile,
} from "./doc-baseline.js";
describe("config doc baseline", () => {
const tempRoots: string[] = [];
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map(async (tempRoot) => {
await fs.rm(tempRoot, { recursive: true, force: true });
}),
);
});
it("is deterministic across repeated runs", async () => {
const first = await renderConfigDocBaselineStatefile();
const second = await renderConfigDocBaselineStatefile();
expect(second.json).toBe(first.json);
expect(second.jsonl).toBe(first.jsonl);
});
it("normalizes array and record paths to wildcard form", async () => {
const baseline = await buildConfigDocBaseline();
const paths = new Set(baseline.entries.map((entry) => entry.path));
expect(paths.has("session.sendPolicy.rules.*.match.keyPrefix")).toBe(true);
expect(paths.has("env.*")).toBe(true);
expect(normalizeConfigDocBaselineHelpPath("agents.list[].skills")).toBe("agents.list.*.skills");
});
it("includes core, channel, and plugin config metadata", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("gateway.auth.token")).toMatchObject({
kind: "core",
sensitive: true,
});
expect(byPath.get("channels.telegram.botToken")).toMatchObject({
kind: "channel",
sensitive: true,
});
expect(byPath.get("plugins.entries.voice-call.config.twilio.authToken")).toMatchObject({
kind: "plugin",
sensitive: true,
});
});
it("preserves help text and tags from merged schema hints", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
const tokenEntry = byPath.get("gateway.auth.token");
expect(tokenEntry?.help).toContain("gateway access");
expect(tokenEntry?.tags).toContain("auth");
expect(tokenEntry?.tags).toContain("security");
});
it("matches array help hints that still use [] notation", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("session.sendPolicy.rules.*.match.keyPrefix")).toMatchObject({
help: expect.stringContaining("prefer rawKeyPrefix when exact full-key matching is required"),
sensitive: false,
});
});
it("walks union branches for nested config keys", async () => {
const baseline = await buildConfigDocBaseline();
const byPath = new Map(baseline.entries.map((entry) => [entry.path, entry]));
expect(byPath.get("bindings.*")).toMatchObject({
hasChildren: true,
});
expect(byPath.get("bindings.*.type")).toBeDefined();
expect(byPath.get("bindings.*.match.channel")).toBeDefined();
expect(byPath.get("bindings.*.match.peer.id")).toBeDefined();
});
it("merges tuple item metadata instead of dropping earlier entries", () => {
const entries = dedupeConfigDocBaselineEntries(
collectConfigDocBaselineEntries(
{
type: "array",
items: [
{
type: "string",
enum: ["alpha"],
},
{
type: "number",
enum: [42],
},
],
},
{},
"tupleValues",
),
);
const tupleEntry = new Map(entries.map((entry) => [entry.path, entry])).get("tupleValues.*");
expect(tupleEntry).toMatchObject({
type: ["number", "string"],
});
expect(tupleEntry?.enumValues).toEqual(expect.arrayContaining([42, "alpha"]));
expect(tupleEntry?.enumValues).toHaveLength(2);
});
it("supports check mode for stale generated artifacts", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-doc-baseline-"));
tempRoots.push(tempRoot);
const initial = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
});
expect(initial.wrote).toBe(true);
const current = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
});
expect(current.changed).toBe(false);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.json"),
'{"generatedBy":"broken","entries":[]}\n',
"utf8",
);
await fs.writeFile(
path.join(tempRoot, "docs/.generated/config-baseline.jsonl"),
'{"recordType":"meta","generatedBy":"broken","totalPaths":0}\n',
"utf8",
);
const stale = await writeConfigDocBaselineStatefile({
repoRoot: tempRoot,
jsonPath: "docs/.generated/config-baseline.json",
statefilePath: "docs/.generated/config-baseline.jsonl",
check: true,
});
expect(stale.changed).toBe(true);
expect(stale.wrote).toBe(false);
});
});

578
src/config/doc-baseline.ts Normal file
View File

@ -0,0 +1,578 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { ChannelPlugin } from "../channels/plugins/index.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import { FIELD_HELP } from "./schema.help.js";
import { buildConfigSchema, type ConfigSchemaResponse } from "./schema.js";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
type JsonSchemaNode = Record<string, unknown>;
type JsonSchemaObject = JsonSchemaNode & {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
required?: string[];
additionalProperties?: JsonSchemaObject | boolean;
items?: JsonSchemaObject | JsonSchemaObject[];
enum?: unknown[];
default?: unknown;
deprecated?: boolean;
anyOf?: JsonSchemaObject[];
allOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
};
export type ConfigDocBaselineKind = "core" | "channel" | "plugin";
export type ConfigDocBaselineEntry = {
path: string;
kind: ConfigDocBaselineKind;
type?: string | string[];
required: boolean;
enumValues?: JsonValue[];
defaultValue?: JsonValue;
deprecated: boolean;
sensitive: boolean;
tags: string[];
label?: string;
help?: string;
hasChildren: boolean;
};
export type ConfigDocBaseline = {
generatedBy: "scripts/generate-config-doc-baseline.ts";
entries: ConfigDocBaselineEntry[];
};
export type ConfigDocBaselineStatefileRender = {
json: string;
jsonl: string;
baseline: ConfigDocBaseline;
};
export type ConfigDocBaselineStatefileWriteResult = {
changed: boolean;
wrote: boolean;
jsonPath: string;
statefilePath: string;
};
const GENERATED_BY = "scripts/generate-config-doc-baseline.ts" as const;
const DEFAULT_JSON_OUTPUT = "docs/.generated/config-baseline.json";
const DEFAULT_STATEFILE_OUTPUT = "docs/.generated/config-baseline.jsonl";
function resolveRepoRoot(): string {
const fromPackage = resolveOpenClawPackageRootSync({
cwd: path.dirname(fileURLToPath(import.meta.url)),
moduleUrl: import.meta.url,
});
if (fromPackage) {
return fromPackage;
}
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
}
function normalizeBaselinePath(rawPath: string): string {
return rawPath
.trim()
.replace(/\[\]/g, ".*")
.replace(/\[(\*|\d+)\]/g, ".*")
.replace(/^\.+|\.+$/g, "")
.replace(/\.+/g, ".");
}
function normalizeJsonValue(value: unknown): JsonValue | undefined {
if (value === null) {
return null;
}
if (typeof value === "string" || typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : undefined;
}
if (Array.isArray(value)) {
const normalized = value
.map((entry) => normalizeJsonValue(entry))
.filter((entry): entry is JsonValue => entry !== undefined);
return normalized;
}
if (!value || typeof value !== "object") {
return undefined;
}
const entries = Object.entries(value as Record<string, unknown>)
.toSorted(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => {
const normalized = normalizeJsonValue(entry);
return normalized === undefined ? null : ([key, normalized] as const);
})
.filter((entry): entry is readonly [string, JsonValue] => entry !== null);
return Object.fromEntries(entries);
}
function normalizeEnumValues(values: unknown[] | undefined): JsonValue[] | undefined {
if (!values) {
return undefined;
}
const normalized = values
.map((entry) => normalizeJsonValue(entry))
.filter((entry): entry is JsonValue => entry !== undefined);
return normalized.length > 0 ? normalized : undefined;
}
function asSchemaObject(value: unknown): JsonSchemaObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonSchemaObject;
}
function schemaHasChildren(schema: JsonSchemaObject): boolean {
if (schema.properties && Object.keys(schema.properties).length > 0) {
return true;
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
return true;
}
if (Array.isArray(schema.items)) {
return schema.items.some((entry) => typeof entry === "object" && entry !== null);
}
for (const branch of [schema.oneOf, schema.anyOf, schema.allOf]) {
if (branch?.some((entry) => entry && typeof entry === "object" && schemaHasChildren(entry))) {
return true;
}
}
return Boolean(schema.items && typeof schema.items === "object");
}
function splitHintLookupPath(path: string): string[] {
const normalized = normalizeBaselinePath(path);
return normalized ? normalized.split(".").filter(Boolean) : [];
}
function resolveUiHintMatch(
uiHints: ConfigSchemaResponse["uiHints"],
path: string,
): ConfigSchemaResponse["uiHints"][string] | undefined {
const targetParts = splitHintLookupPath(path);
let bestMatch:
| {
hint: ConfigSchemaResponse["uiHints"][string];
wildcardCount: number;
}
| undefined;
for (const [hintPath, hint] of Object.entries(uiHints)) {
const hintParts = splitHintLookupPath(hintPath);
if (hintParts.length !== targetParts.length) {
continue;
}
let wildcardCount = 0;
let matches = true;
for (let index = 0; index < hintParts.length; index += 1) {
const hintPart = hintParts[index];
const targetPart = targetParts[index];
if (hintPart === targetPart) {
continue;
}
if (hintPart === "*") {
wildcardCount += 1;
continue;
}
matches = false;
break;
}
if (!matches) {
continue;
}
if (!bestMatch || wildcardCount < bestMatch.wildcardCount) {
bestMatch = { hint, wildcardCount };
}
}
return bestMatch?.hint;
}
function normalizeTypeValue(value: string | string[] | undefined): string | string[] | undefined {
if (!value) {
return undefined;
}
if (Array.isArray(value)) {
const normalized = [...new Set(value)].toSorted((left, right) => left.localeCompare(right));
return normalized.length === 1 ? normalized[0] : normalized;
}
return value;
}
function mergeTypeValues(
left: string | string[] | undefined,
right: string | string[] | undefined,
): string | string[] | undefined {
const merged = new Set<string>();
for (const value of [left, right]) {
if (!value) {
continue;
}
if (Array.isArray(value)) {
for (const entry of value) {
merged.add(entry);
}
continue;
}
merged.add(value);
}
return normalizeTypeValue([...merged]);
}
function areJsonValuesEqual(left: JsonValue | undefined, right: JsonValue | undefined): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
function mergeJsonValueArrays(
left: JsonValue[] | undefined,
right: JsonValue[] | undefined,
): JsonValue[] | undefined {
if (!left?.length) {
return right ? [...right] : undefined;
}
if (!right?.length) {
return [...left];
}
const merged = new Map<string, JsonValue>();
for (const value of [...left, ...right]) {
merged.set(JSON.stringify(value), value);
}
return [...merged.entries()]
.toSorted(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
.map(([, value]) => value);
}
function mergeConfigDocBaselineEntry(
current: ConfigDocBaselineEntry,
next: ConfigDocBaselineEntry,
): ConfigDocBaselineEntry {
const label = current.label === next.label ? current.label : (current.label ?? next.label);
const help = current.help === next.help ? current.help : (current.help ?? next.help);
const defaultValue = areJsonValuesEqual(current.defaultValue, next.defaultValue)
? (current.defaultValue ?? next.defaultValue)
: undefined;
return {
path: current.path,
kind: current.kind,
type: mergeTypeValues(current.type, next.type),
required: current.required && next.required,
enumValues: mergeJsonValueArrays(current.enumValues, next.enumValues),
defaultValue,
deprecated: current.deprecated || next.deprecated,
sensitive: current.sensitive || next.sensitive,
tags: [...new Set([...current.tags, ...next.tags])].toSorted((left, right) =>
left.localeCompare(right),
),
label,
help,
hasChildren: current.hasChildren || next.hasChildren,
};
}
function resolveEntryKind(configPath: string): ConfigDocBaselineKind {
if (configPath.startsWith("channels.")) {
return "channel";
}
if (configPath.startsWith("plugins.entries.")) {
return "plugin";
}
return "core";
}
async function resolveFirstExistingPath(candidates: string[]): Promise<string | null> {
for (const candidate of candidates) {
try {
await fs.access(candidate);
return candidate;
} catch {
// Keep scanning for other source file variants.
}
}
return null;
}
function isChannelPlugin(value: unknown): value is ChannelPlugin {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as { id?: unknown; meta?: unknown; capabilities?: unknown };
return typeof candidate.id === "string" && typeof candidate.meta === "object";
}
async function importChannelPluginModule(rootDir: string): Promise<ChannelPlugin> {
const modulePath = await resolveFirstExistingPath([
path.join(rootDir, "src", "channel.ts"),
path.join(rootDir, "src", "channel.js"),
path.join(rootDir, "src", "plugin.ts"),
path.join(rootDir, "src", "plugin.js"),
path.join(rootDir, "src", "index.ts"),
path.join(rootDir, "src", "index.js"),
path.join(rootDir, "src", "channel.mts"),
path.join(rootDir, "src", "channel.mjs"),
path.join(rootDir, "src", "plugin.mts"),
path.join(rootDir, "src", "plugin.mjs"),
]);
if (!modulePath) {
throw new Error(`channel source not found under ${rootDir}`);
}
const imported = (await import(pathToFileURL(modulePath).href)) as Record<string, unknown>;
for (const value of Object.values(imported)) {
if (isChannelPlugin(value)) {
return value;
}
if (typeof value === "function" && value.length === 0) {
const resolved = value();
if (isChannelPlugin(resolved)) {
return resolved;
}
}
}
throw new Error(`channel plugin export not found in ${modulePath}`);
}
async function loadBundledConfigSchemaResponse(): Promise<ConfigSchemaResponse> {
const repoRoot = resolveRepoRoot();
const env = {
...process.env,
HOME: os.tmpdir(),
OPENCLAW_STATE_DIR: path.join(os.tmpdir(), "openclaw-config-doc-baseline-state"),
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(repoRoot, "extensions"),
};
const manifestRegistry = loadPluginManifestRegistry({
cache: false,
env,
config: {},
});
const channelPlugins = await Promise.all(
manifestRegistry.plugins
.filter((plugin) => plugin.origin === "bundled" && plugin.channels.length > 0)
.map(async (plugin) => ({
id: plugin.id,
channel: await importChannelPluginModule(plugin.rootDir),
})),
);
return buildConfigSchema({
plugins: manifestRegistry.plugins
.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configSchema,
})),
channels: channelPlugins.map((entry) => ({
id: entry.channel.id,
label: entry.channel.meta.label,
description: entry.channel.meta.blurb,
configSchema: entry.channel.configSchema?.schema,
configUiHints: entry.channel.configSchema?.uiHints,
})),
});
}
export function collectConfigDocBaselineEntries(
schema: JsonSchemaObject,
uiHints: ConfigSchemaResponse["uiHints"],
pathPrefix = "",
required = false,
entries: ConfigDocBaselineEntry[] = [],
): ConfigDocBaselineEntry[] {
const normalizedPath = normalizeBaselinePath(pathPrefix);
if (normalizedPath) {
const hint = resolveUiHintMatch(uiHints, normalizedPath);
entries.push({
path: normalizedPath,
kind: resolveEntryKind(normalizedPath),
type: normalizeTypeValue(schema.type),
required,
enumValues: normalizeEnumValues(schema.enum),
defaultValue: normalizeJsonValue(schema.default),
deprecated: schema.deprecated === true,
sensitive: hint?.sensitive === true,
tags: [...(hint?.tags ?? [])].toSorted((left, right) => left.localeCompare(right)),
label: hint?.label,
help: hint?.help,
hasChildren: schemaHasChildren(schema),
});
}
const requiredKeys = new Set(schema.required ?? []);
for (const key of Object.keys(schema.properties ?? {}).toSorted((left, right) =>
left.localeCompare(right),
)) {
const child = asSchemaObject(schema.properties?.[key]);
if (!child) {
continue;
}
const childPath = normalizedPath ? `${normalizedPath}.${key}` : key;
collectConfigDocBaselineEntries(child, uiHints, childPath, requiredKeys.has(key), entries);
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
const wildcard = asSchemaObject(schema.additionalProperties);
if (wildcard) {
const wildcardPath = normalizedPath ? `${normalizedPath}.*` : "*";
collectConfigDocBaselineEntries(wildcard, uiHints, wildcardPath, false, entries);
}
}
if (Array.isArray(schema.items)) {
for (const item of schema.items) {
const child = asSchemaObject(item);
if (!child) {
continue;
}
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
collectConfigDocBaselineEntries(child, uiHints, itemPath, false, entries);
}
} else if (schema.items && typeof schema.items === "object") {
const itemSchema = asSchemaObject(schema.items);
if (itemSchema) {
const itemPath = normalizedPath ? `${normalizedPath}.*` : "*";
collectConfigDocBaselineEntries(itemSchema, uiHints, itemPath, false, entries);
}
}
for (const branchSchema of [schema.oneOf, schema.anyOf, schema.allOf]) {
for (const branch of branchSchema ?? []) {
const child = asSchemaObject(branch);
if (!child) {
continue;
}
collectConfigDocBaselineEntries(child, uiHints, normalizedPath, required, entries);
}
}
return entries;
}
export function dedupeConfigDocBaselineEntries(
entries: ConfigDocBaselineEntry[],
): ConfigDocBaselineEntry[] {
const byPath = new Map<string, ConfigDocBaselineEntry>();
for (const entry of entries) {
const current = byPath.get(entry.path);
byPath.set(entry.path, current ? mergeConfigDocBaselineEntry(current, entry) : entry);
}
return [...byPath.values()].toSorted((left, right) => left.path.localeCompare(right.path));
}
export async function buildConfigDocBaseline(): Promise<ConfigDocBaseline> {
const response = await loadBundledConfigSchemaResponse();
const schemaRoot = asSchemaObject(response.schema);
if (!schemaRoot) {
throw new Error("config schema root is not an object");
}
const entries = dedupeConfigDocBaselineEntries(
collectConfigDocBaselineEntries(schemaRoot, response.uiHints),
);
return {
generatedBy: GENERATED_BY,
entries,
};
}
export async function renderConfigDocBaselineStatefile(
baseline?: ConfigDocBaseline,
): Promise<ConfigDocBaselineStatefileRender> {
const resolvedBaseline = baseline ?? (await buildConfigDocBaseline());
const json = `${JSON.stringify(resolvedBaseline, null, 2)}\n`;
const metadataLine = JSON.stringify({
generatedBy: GENERATED_BY,
recordType: "meta",
totalPaths: resolvedBaseline.entries.length,
});
const entryLines = resolvedBaseline.entries.map((entry) =>
JSON.stringify({
recordType: "path",
...entry,
}),
);
return {
json,
jsonl: `${[metadataLine, ...entryLines].join("\n")}\n`,
baseline: resolvedBaseline,
};
}
async function readIfExists(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, "utf8");
} catch {
return null;
}
}
async function writeIfChanged(filePath: string, next: string): Promise<boolean> {
const current = await readIfExists(filePath);
if (current === next) {
return false;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, next, "utf8");
return true;
}
export async function writeConfigDocBaselineStatefile(params?: {
repoRoot?: string;
check?: boolean;
jsonPath?: string;
statefilePath?: string;
}): Promise<ConfigDocBaselineStatefileWriteResult> {
const repoRoot = params?.repoRoot ?? resolveRepoRoot();
const jsonPath = path.resolve(repoRoot, params?.jsonPath ?? DEFAULT_JSON_OUTPUT);
const statefilePath = path.resolve(repoRoot, params?.statefilePath ?? DEFAULT_STATEFILE_OUTPUT);
const rendered = await renderConfigDocBaselineStatefile();
const currentJson = await readIfExists(jsonPath);
const currentStatefile = await readIfExists(statefilePath);
const changed = currentJson !== rendered.json || currentStatefile !== rendered.jsonl;
if (params?.check) {
return {
changed,
wrote: false,
jsonPath,
statefilePath,
};
}
const wroteJson = await writeIfChanged(jsonPath, rendered.json);
const wroteStatefile = await writeIfChanged(statefilePath, rendered.jsonl);
return {
changed,
wrote: wroteJson || wroteStatefile,
jsonPath,
statefilePath,
};
}
export function normalizeConfigDocBaselineHelpPath(pathValue: string): string {
return normalizeBaselinePath(pathValue);
}
export function getNormalizedFieldHelp(): Record<string, string> {
return Object.fromEntries(
Object.entries(FIELD_HELP)
.map(([configPath, help]) => [normalizeBaselinePath(configPath), help] as const)
.toSorted(([left], [right]) => left.localeCompare(right)),
);
}

View File

@ -31,6 +31,7 @@ const AGENT_HEARTBEAT_KEYS = new Set([
"ackMaxChars",
"suppressToolErrorWarnings",
"lightContext",
"isolatedSession",
]);
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);

View File

@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import {
clearPluginManifestRegistryCache,
@ -11,7 +11,6 @@ import { validateConfigObject } from "./config.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
const tempDirs: string[] = [];
const previousUmask = process.umask(0o022);
function chmodSafeDir(dir: string) {
if (process.platform === "win32") {
@ -126,10 +125,6 @@ afterEach(() => {
}
});
afterAll(() => {
process.umask(previousUmask);
});
describe("applyPluginAutoEnable", () => {
it("auto-enables built-in channels and appends to existing allowlist", () => {
const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } });

View File

@ -102,6 +102,10 @@ export const FIELD_HELP: Record<string, string> = {
"Explicit gateway-level tool denylist to block risky tools even if lower-level policies allow them. Use deny rules for emergency response and defense-in-depth hardening.",
"gateway.channelHealthCheckMinutes":
"Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.",
"gateway.channelStaleEventThresholdMinutes":
"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.",
"gateway.channelMaxRestartsPerHour":
"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.",
"gateway.tailscale":
"Tailscale integration settings for Serve/Funnel exposure and lifecycle handling on gateway start/exit. Keep off unless your deployment intentionally relies on Tailscale ingress.",
"gateway.tailscale.mode":

View File

@ -84,6 +84,8 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.tools.allow": "Gateway Tool Allowlist",
"gateway.tools.deny": "Gateway Tool Denylist",
"gateway.channelHealthCheckMinutes": "Gateway Channel Health Check Interval (min)",
"gateway.channelStaleEventThresholdMinutes": "Gateway Channel Stale Event Threshold (min)",
"gateway.channelMaxRestartsPerHour": "Gateway Channel Max Restarts Per Hour",
"gateway.tailscale": "Gateway Tailscale",
"gateway.tailscale.mode": "Gateway Tailscale Mode",
"gateway.tailscale.resetOnExit": "Gateway Tailscale Reset on Exit",

View File

@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { normalizeConfigDocBaselineHelpPath } from "./doc-baseline.js";
import { FIELD_HELP } from "./schema.help.js";
import {
describeTalkSilenceTimeoutDefaults,
@ -17,8 +18,18 @@ function readRepoFile(relativePath: string): string {
describe("talk silence timeout defaults", () => {
it("keeps help text and docs aligned with the policy", () => {
const defaultsDescription = describeTalkSilenceTimeoutDefaults();
const baselineLines = readRepoFile("docs/.generated/config-baseline.jsonl")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { recordType: string; path?: string; help?: string });
const talkEntry = baselineLines.find(
(entry) =>
entry.recordType === "path" &&
entry.path === normalizeConfigDocBaselineHelpPath("talk.silenceTimeoutMs"),
);
expect(FIELD_HELP["talk.silenceTimeoutMs"]).toContain(defaultsDescription);
expect(talkEntry?.help).toContain(defaultsDescription);
expect(readRepoFile("docs/gateway/configuration-reference.md")).toContain(defaultsDescription);
expect(readRepoFile("docs/nodes/talk.md")).toContain(defaultsDescription);
});

Some files were not shown because too many files have changed in this diff Show More