Compare commits

..

86 Commits

Author SHA1 Message Date
Peter Steinberger e599c66277 test: harden canvas host undici isolation
Docker Release / validate_manual_backfill (push) Has been skipped
Docker Release / approve_manual_backfill (push) Has been skipped
Docker Release / build-amd64 (push) Failing after 36s
Docker Release / create-manifest (push) Has been cancelled
Docker Release / build-arm64 (push) Has been cancelled
2026-03-23 11:54:49 -07:00
Peter Steinberger 870b0d216a build: prepare 2026.3.23-beta.1 2026-03-23 11:54:49 -07:00
Vincent Koc 8fa91d283b fix(cli): preserve posix default git dir 2026-03-23 11:49:55 -07:00
Val Alexander 7a459045de fix packaged control ui asset lookup (#53081) 2026-03-23 13:45:04 -05:00
Peter Steinberger 323ae73b30 fix: bound tracked debounce key accounting 2026-03-23 11:33:06 -07:00
scoootscooob 708cccd43b docs(changelog): add missing recent fixes 2026-03-23 11:26:26 -07:00
scoootscooob cdc8bac466 fix(discord): reply on native command auth failures (#53072) 2026-03-23 11:20:58 -07:00
Peter Steinberger 1b69d9ee1a fix: preserve debounce and followup ordering (#52998) (thanks @osolmaz) 2026-03-23 11:11:06 -07:00
Vincent Koc 9b7cfd0bea fix(inbound): bound tracked debounce keys 2026-03-23 11:11:06 -07:00
Vincent Koc 02e07a157d fix(reply): clear idle followup callbacks 2026-03-23 11:11:06 -07:00
Vincent Koc 3de42e946a fix(reply): preserve no-debounce inbound concurrency 2026-03-23 11:11:06 -07:00
Vincent Koc 7bc8e67d2a Update CHANGELOG.md 2026-03-23 11:11:06 -07:00
Vincent Koc a35dcf608e fix(reply): refresh followup drain callbacks 2026-03-23 11:11:06 -07:00
Onur Solmaz bcaadc39ea Telegram: fix fire-and-forget debounce order 2026-03-23 11:11:06 -07:00
Onur Solmaz 9a34a602bd Telegram: preserve inbound debounce order 2026-03-23 11:11:06 -07:00
Vincent Koc b15462ebaf Revert "fix(plugins): enable bundled Brave web search plugin by default (#52072)"
This reverts commit 0ea3c4d5d8.
2026-03-23 11:04:02 -07:00
Peter Steinberger 80bd5ba728 fix(release): fail empty control ui tarballs 2026-03-23 11:03:14 -07:00
Quinn H. 0ea3c4d5d8 fix(plugins): enable bundled Brave web search plugin by default (#52072)
Brave is a bundled web search plugin but was missing from
BUNDLED_ENABLED_BY_DEFAULT, causing it to be filtered out during
provider resolution. This made web_search unavailable even when
plugins.entries.brave.enabled was configured.

Fixes #51937

Co-authored-by: Ubuntu <ubuntu@ip-172-26-10-234.us-west-2.compute.internal>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 11:01:02 -07:00
Vincent Koc dd586d59ed fix(mistral): repair max-token defaults and doctor migration (#53054)
* fix(mistral): repair max-token defaults and doctor migration

* fix(mistral): add missing small-model repair cap
2026-03-23 10:57:56 -07:00
Peter Steinberger ffb287e1de fix: harden update dev switch and refresh changelog 2026-03-23 10:56:35 -07:00
Nimrod Gutman 848414d7f2 docs(changelog): add channel catalog override note (#52988) (#53059) 2026-03-23 19:52:32 +02:00
TheRipper 36d6ba55e3 Release: fix npm release preflight under pnpm (#52985)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 10:51:09 -07:00
Peter Steinberger f9a7427e8e test: refresh thread-safe agent fixtures 2026-03-23 10:47:02 -07:00
Vincent Koc 2592eb0796 fix(gateway): guard openrouter auto pricing recursion (#53055) 2026-03-23 10:43:01 -07:00
Vincent Koc 3fe2f0a550 docs: fix CLI command tree, SDK import path, and tool group listing
- Remove non-existent 'secrets migrate' from CLI command tree
- Add actual secrets subcommands: audit, configure, apply
- Add missing plugin subcommands: inspect, uninstall, update, marketplace list
- Fix plugins info -> inspect (actual command name)
- Add message send and broadcast subcommands to command tree
- Remove misleading deprecated import from sdk-overview
- Add sessions_yield and subagents to group:sessions tool group docs
- Fix formatting
2026-03-23 10:40:41 -07:00
Jamil Zakirov 921a147196 Agents: fix runtime web_search provider selection (#53020)
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 10:30:45 -07:00
scoootscooob df18063260 fix(clawhub): preserve XDG auth path on macOS 2026-03-23 10:21:28 -07:00
Vincent Koc d1c95c84b5 docs: fix nav ordering, missing pages, and stale model references
- Sort providers alphabetically in docs.json nav
- Sort channels alphabetically in docs.json nav (slack before synology-chat)
- Add install/migrating-matrix to Maintenance nav section (was orphaned)
- Remove zh-CN/plugins/architecture from nav (file does not exist)
- Add Voice Call to channels index page
- Add missing providers to providers index (DeepSeek, GitHub Copilot, OpenCode Go, Synthetic)
- Sort providers index alphabetically
- Update stale claude-3-5-sonnet model reference to claude-sonnet-4-6 in webhook docs
2026-03-23 10:20:15 -07:00
scoootscooob 5b09463bc3 fix(clawhub): honor macOS auth config path (#53034) 2026-03-23 10:14:23 -07:00
Vincent Koc b4dd600b37 fix(browser): reuse running loopback browser after probe miss 2026-03-23 10:05:26 -07:00
Vincent Koc a1df10caac fix(config): ignore stale plugin allow entries 2026-03-23 09:57:46 -07:00
Luke d98e3a1ea9 Tests: fix fresh-main regressions (#53011)
* Tests: fix fresh-main regressions

* Tests: avoid chat notice cache priming

---------

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-03-23 09:54:50 -07:00
Vincent Koc c55d4f63eb fix(release): raise npm pack size budget 2026-03-23 09:54:28 -07:00
Vincent Koc 071de383ff fix(clawhub): resolve auth token for skill browsing (#53017)
* fix(clawhub): resolve auth token for skill browsing

* docs(changelog): note clawhub skill auth fix
2026-03-23 09:53:40 -07:00
Vincent Koc d5dc6b6573 fix(gateway): require auth for canvas routes 2026-03-23 09:31:40 -07:00
Vincent Koc b0ce53a79c fix(voice-call): stabilize plivo v2 replay keys 2026-03-23 09:31:40 -07:00
Vincent Koc 50f6a2f136 fix(gateway): require admin for agent session reset 2026-03-23 09:31:39 -07:00
Nimrod Gutman 041c47419f fix(channels): preserve external catalog overrides (#52988)
* fix(channels): preserve external catalog overrides

* fix(channels): clarify catalog precedence

* fix(channels): respect overridden install specs
2026-03-23 18:08:17 +02:00
Vincent Koc 29ad211e76 fix(plugins): unblock Discord/Slack message tool sends and Feishu media (#52991)
* fix(plugins): unblock Discord and Slack message tool payloads

* docs(changelog): note Discord Slack and Feishu message fixes
2026-03-23 09:04:57 -07:00
Vincent Koc 83e86482c6 docs(changelog): note release and matrix fixes 2026-03-23 08:51:46 -07:00
Vincent Koc 93df5f613e fix(gateway): avoid probe false negatives after connect 2026-03-23 08:49:31 -07:00
Vincent Koc 1e5f38a1a8 fix(matrix): avoid duplicate runtime api exports 2026-03-23 08:46:58 -07:00
Vincent Koc beadd4c553 fix(gateway): harden supervised lock and browser attach readiness 2026-03-23 08:42:00 -07:00
Nimrod Gutman b84a130788 fix(release): preserve shipped channel surfaces in npm tar (#52913)
* fix(channels): ship official channel catalog (#52838)

* fix(release): keep shipped bundles in npm tar (#52838)

* build(release): fix rebased release-check helpers (#52838)
2026-03-23 17:39:22 +02:00
Vincent Koc 7299b42e2a fix(config): keep built-in auto-enable idempotent 2026-03-23 08:34:37 -07:00
Vincent Koc 2615402a2b Update CHANGELOG.md 2026-03-23 08:29:02 -07:00
Vincent Koc e68cbea5b4 fix(config): keep built-in channels out of plugin allowlists (#52964)
* fix(config): keep built-in channels out of plugin allowlists

* docs(changelog): note doctor whatsapp allowlist fix

* docs(changelog): move doctor whatsapp fix to top
2026-03-23 08:26:51 -07:00
Vincent Koc 70b235f312 fix(release): ship bundled plugins in pack artifacts 2026-03-23 08:22:00 -07:00
Vincent Koc 31675d65d4 fix(agents): preserve anthropic thinking block order (#52961) 2026-03-23 08:18:15 -07:00
Tak Hoffman 6872e079e6 Improve PR template regression prompts 2026-03-23 10:12:59 -05:00
Vincent Koc 8a8ed34716 Update CHANGELOG.md 2026-03-23 08:10:57 -07:00
Onur Solmaz 8ed33c2aff release: automate macOS publishing (#52853)
* release: automate macOS publishing

* release: keep mac appcast in openclaw repo

* release: add preflight-only release workflow runs

* release: keep appcast updates manual

* release: generate signed appcast as workflow artifact

* release: require preflight before publish

* release: require mac app for every release

* docs: clarify every release ships mac app

* release: document Sparkle feed and SHA rules

* release: keep publish flow tag-based

* release: stabilize mac appcast flow

* release: document local mac fallback
2026-03-23 16:04:53 +01:00
Vincent Koc e9078b3ff6 fix(msteams): isolate probe test env credentials 2026-03-23 08:00:54 -07:00
Sathvik Veerapaneni d2e8ed3632 fix: keep session transcript pointers fresh after compaction (#50688)
Co-authored-by: Frank Yang <frank.ekn@gmail.com>
2026-03-23 22:58:07 +08:00
Robin Waslander dd132ea77b release: add changelog for control UI tarball check 2026-03-23 15:40:36 +01:00
evann c278e8d7a8 release-check: include stderr/stdout when npm pack fails 2026-03-23 15:40:35 +01:00
evann fcafd8d4de release: verify control-ui assets are included in npm tarball 2026-03-23 15:40:34 +01:00
Peter Steinberger b2bf508dc5 docs: refresh generated config baseline 2026-03-23 14:38:57 +00:00
Peter Steinberger 05e31eb9ff test: stabilize channel ci gate 2026-03-23 14:32:10 +00:00
Peter Steinberger 3725b38335 fix: restore ci gates 2026-03-23 14:32:10 +00:00
wangchunyue 4e849ac127 fix: ensure env proxy dispatcher before MiniMax and OpenAI Codex OAuth flows (openclaw#52228)
Verified:
- pnpm install --frozen-lockfile
- NPM_CONFIG_CACHE=/tmp/openclaw-npm-cache-52228 pnpm build
- pnpm check
- pnpm test:macmini (failed on inherited pre-existing plugin contract test: src/plugins/contracts/registry.contract.test.ts missing deepseek in bundled provider contract registry outside this PR surface)

Co-authored-by: openperf <80630709+openperf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-23 09:26:34 -05:00
Peter Steinberger 8fdb9194d2 build: sync lockfile for mattermost plugin 2026-03-23 12:59:15 +00:00
Peter Steinberger b393effba6 test: harden channel suite isolation 2026-03-23 12:57:43 +00:00
Peter Steinberger 949d6be1d1 test: fix status plugin pagination expectation 2026-03-23 05:51:19 -07:00
Peter Steinberger 383c61e305 test: stabilize gateway thread harness 2026-03-23 05:44:34 -07:00
Onur Solmaz cd7d49b48e CI: remove npm release preview workflow (#52825)
* CI: remove npm release preview workflow

* Docs: align release maintainer skill with manual publish

* Docs: expand release maintainer skill flow
2026-03-23 13:35:57 +01:00
Peter Steinberger 41850c3880 refactor: isolate attempt context engine thread helpers 2026-03-23 05:27:44 -07:00
Peter Steinberger 76dc854963 test: remove replaced spawn-workspace monolith 2026-03-23 05:21:59 -07:00
Peter Steinberger 4e661d5c4b test: split attempt spawn-workspace thread fixtures 2026-03-23 05:21:59 -07:00
Peter Steinberger 08b5ba1a12 test: reset line webhook mocks between cases 2026-03-23 12:13:11 +00:00
Ayaan Zaidi 69b9e44762 fix: declare typebox runtime dep for mattermost plugin 2026-03-23 17:39:30 +05:30
Peter Steinberger 9f2330363e fix: refactor deepseek bundled plugin (#48762) (thanks @07akioni) 2026-03-23 05:06:07 -07:00
Bakhtier Sizhaev a0cb443aa3 fix: document Telegram asDocument alias (#52461) (thanks @bakhtiersizhaev)
* feat(telegram): add asDocument param to message tool

Adds `asDocument` as a user-facing alias for the existing `forceDocument`
parameter in the message tool. When set to `true`, media files (images,
videos, GIFs) are sent via `sendDocument` instead of `sendPhoto`/
`sendVideo`/`sendAnimation`, preserving the original file quality
without Telegram compression.

This is useful when agents need to deliver high-resolution images or
uncompressed files to users via Telegram.

`asDocument` is intentionally an alias rather than a replacement — the
existing `forceDocument` continues to work unchanged.

Changes:
- src/agents/tools/message-tool.ts: add asDocument to send schema
- src/agents/tools/telegram-actions.ts: OR asDocument into forceDocument
- src/infra/outbound/message-action-runner.ts: same OR logic for outbound path
- extensions/telegram/src/channel-actions.ts: read and forward asDocument
- src/channels/plugins/actions/actions.test.ts: add test case

* fix: restore channel-actions.ts to main version (rebase conflict fix)

* fix(test): match asDocument test payload to actual params structure

* fix(telegram): preserve forceDocument alias semantics

* fix: document Telegram asDocument alias (#52461) (thanks @bakhtiersizhaev)

---------

Co-authored-by: Бахтиер Сижаев <bkh@MacBook-Air.local>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-23 17:32:46 +05:30
Peter Steinberger 47db5abece test: inject thread-safe base seams 2026-03-23 04:59:15 -07:00
Peter Steinberger 8fd2fa13c6 test: avoid repo-root perf profile artifacts 2026-03-23 11:51:26 +00:00
Peter Steinberger f98811a67c fix: harden parallels smoke agent invocation 2026-03-23 04:51:07 -07:00
Peter Steinberger b3844d920a test: decouple vitest config checks from ambient env 2026-03-23 11:48:19 +00:00
Peter Steinberger e84ca730a3 test: stabilize e2e module isolation 2026-03-23 11:46:54 +00:00
Peter Steinberger 4f92eaad37 test: inject image-tool provider deps for raw threads 2026-03-23 04:40:58 -07:00
Peter Steinberger a6c7ad84ba build: prepare 2026.3.23 2026-03-23 04:40:13 -07:00
Peter Steinberger 6a3d4f9fad test: isolate pi model and reset-model thread fixtures 2026-03-23 04:36:06 -07:00
Peter Steinberger 6bcd9a801a test: inject thread-safe gateway and ACP seams 2026-03-23 04:34:42 -07:00
Peter Steinberger d841d02439 ci: stabilize windows and bun unit lanes 2026-03-23 11:28:25 +00:00
Peter Steinberger adf63eba9f test: raise timeout for slow provider auth normalization 2026-03-23 11:19:26 +00:00
Peter Steinberger f182c3a292 test: inject thread-safe deps for agent tools 2026-03-23 04:16:53 -07:00
Peter Steinberger fc9739313c test: harden channel suite isolation 2026-03-23 11:09:12 +00:00
371 changed files with 10498 additions and 3629 deletions
@@ -23,6 +23,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Preferred entrypoint: `pnpm test:parallels:npm-update`
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- Keep the aggregate npm-update Linux VM name aligned with the default Linux smoke VM (`Ubuntu 24.04.3 ARM64` on Peter's host today). Do not hardcode a different Linux guest in the wrapper unless the per-OS Linux smoke default changed too.
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
@@ -32,6 +33,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Default to the snapshot closest to `macOS 26.3.1 latest`.
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
@@ -11,7 +11,9 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- Do not change version numbers without explicit operator approval.
- Ask permission before any npm publish or release step.
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
- This skill should be sufficient to drive the normal release flow end-to-end.
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
## Keep release channel naming aligned
@@ -31,8 +33,19 @@ Use this skill for release and publish-time workflow. Keep ordinary development
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
- `docs/install/updating.md`
- Peekaboo Xcode project and plist version fields
- Before creating a release tag, make every version location above match the version encoded by that tag.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
- “Bump version everywhere” means all version locations above except `appcast.xml`.
- Release signing and notary credentials live outside the repo in the private maintainer docs.
- Every OpenClaw release ships the npm package and macOS app together.
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
- That shared production Sparkle feed is stable-only. Beta mac releases may
upload assets to the GitHub prerelease, but they must not replace the shared
`appcast.xml` unless a separate beta feed exists.
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
`APP_BUILD` / Sparkle build than the original release so existing installs
see it as newer.
## Build changelog-backed release notes
@@ -62,13 +75,104 @@ For a non-root smoke path:
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
```
## Check all relevant release builds
- Always validate the OpenClaw npm release path before creating the tag.
- Default release checks:
- `pnpm check`
- `pnpm build`
- `node --import tsx scripts/release-check.ts`
- `pnpm release:check`
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
- Check all release-related build surfaces touched by the release, not only the npm package.
- Include mac release readiness in preflight by running or inspecting the mac
packaging, notarization, and appcast flow for every release.
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
- The workflows remain tag-based. The agent is responsible for making sure
preflight runs complete successfully before any publish run starts.
- Any fix after preflight means a new commit. Delete and recreate the tag and
matching GitHub release from the fixed commit, then rerun preflight from
scratch before publishing.
- For stable mac releases, generate the signed `appcast.xml` before uploading
public release assets so the updater feed cannot lag the published binaries.
- Serialize stable appcast-producing runs across tags so two releases do not
generate replacement `appcast.xml` files from the same stale seed.
- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable.
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
## Use the right auth flow
- Core `openclaw` publish uses GitHub trusted publishing.
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- OpenClaw publish uses GitHub trusted publishing.
- The publish run must be started manually with `workflow_dispatch`.
- Both release workflows accept `preflight_only=true` to run CI
validation/build steps without entering the gated publish job.
- npm preflight and macOS preflight must both pass before any publish run
starts.
- The release workflows stay tag-based; rely on the documented release sequence
rather than workflow-level SHA pinning.
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
- Mac publish uses `.github/workflows/macos-release.yml` for build, signing,
notarization, stable-feed `appcast.xml` artifact generation, and release-asset
upload.
- The agent must download the signed `appcast.xml` artifact from a successful
stable mac workflow and then update `appcast.xml` on `main`.
- For beta mac releases, do not update the shared production `appcast.xml`
unless a separate beta Sparkle feed exists.
- `.github/workflows/macos-release.yml` still requires the `mac-release`
environment approval.
- Do not use `NPM_TOKEN` or the plugin OTP flow for OpenClaw releases.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## Fallback local mac publish
- Keep the original local macOS publish workflow available as a fallback in case
CI/CD mac publishing is unavailable or broken.
- Preserve the existing maintainer workflow Peter uses: run it on a real Mac
with local signing, notary, and Sparkle credentials already configured.
- Follow the private maintainer macOS runbook for the local steps:
`scripts/package-mac-dist.sh` to build, sign, notarize, and package the app;
manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the
`appcast.xml` commit to `main`.
- For stable tags, the local fallback may update the shared production
`appcast.xml`.
- For beta tags, the local fallback still publishes the mac assets but must not
update the shared production `appcast.xml` unless a separate beta feed exists.
- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow
when it is working.
## Run the release sequence
1. Confirm the operator explicitly wants to cut a release.
2. Choose the exact target version and git tag.
3. Make every repo version location match that tag before creating it.
4. Update `CHANGELOG.md` and assemble the matching GitHub release notes.
5. Run the full preflight for all relevant release builds, including mac readiness.
6. Confirm the target npm version is not already published.
7. Create and push the git tag.
8. Create or refresh the matching GitHub release.
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
and wait for it to pass.
10. Start `.github/workflows/macos-release.yml` with `preflight_only=true` and
wait for it to pass.
11. If either preflight fails, fix the issue on a new commit, delete the tag
and matching GitHub release, recreate them from the fixed commit, and rerun
both preflights from scratch before continuing. Never reuse old preflight
results after the commit changes.
12. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
the real publish.
13. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
14. Start `.github/workflows/macos-release.yml` for the real publish and wait
for `mac-release` approval and success.
15. For stable releases, let the mac workflow generate the signed
`appcast.xml` artifact before it uploads the public mac assets, then
download that artifact from the successful run, update `appcast.xml` on
`main`, and verify the feed.
16. For beta releases, publish the mac assets but expect no shared production
`appcast.xml` artifact and do not update the shared production feed unless a
separate beta feed exists.
17. After publish, verify npm and any attached release artifacts.
## GHSA advisory work
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
+4
View File
@@ -241,6 +241,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/byteplus/**"
"extensions: deepseek":
- changed-files:
- any-glob-to-any-file:
- "extensions/deepseek/**"
"extensions: anthropic":
- changed-files:
- any-glob-to-any-file:
+26
View File
@@ -31,6 +31,32 @@ Describe the problem and fix in 25 bullets:
- Closes #
- Related #
- [ ] This PR fixes a bug or regression
## Root Cause / Regression History (if applicable)
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
- Root cause:
- Missing detection / guardrail:
- Prior context (`git blame`, prior PR, issue, or refactor if known):
- Why this regressed now:
- If unknown, what was ruled out:
## Regression Test Plan (if applicable)
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
- Coverage level that should have caught this:
- [ ] Unit test
- [ ] Seam / integration test
- [ ] End-to-end test
- [ ] Existing coverage already sufficient
- Target test or file:
- Scenario the test should lock in:
- Why this is the smallest reliable guardrail:
- Existing test that already covers this (if any):
- If no new test is added, why not:
## User-visible / Behavior Changes
+5 -1
View File
@@ -187,6 +187,9 @@ jobs:
- name: Build dist
run: pnpm build
- name: Build Control UI
run: pnpm ui:build
- name: Upload dist artifact
uses: actions/upload-artifact@v7
with:
@@ -254,13 +257,14 @@ jobs:
command: pnpm protocol:check
- runtime: bun
task: test
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts
- runtime: node
task: compat-node22
node_version: "22.x"
cache_key_suffix: "node22"
command: |
pnpm build
pnpm ui:build
node openclaw.mjs --help
node openclaw.mjs status --json --timeout 1
pnpm test:build:singleton
+306
View File
@@ -0,0 +1,306 @@
name: macOS Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag to build macOS artifacts for (for example v2026.3.22 or v2026.3.22-beta.1)
required: true
type: string
preflight_only:
description: Run validation/build only and skip the gated publish job
required: true
default: false
type: boolean
concurrency:
group: macos-release-${{ inputs.tag }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.23.0"
SPARKLE_FEED_URL: https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
jobs:
preflight_macos_release:
runs-on: macos-latest
permissions:
contents: read
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout selected tag
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Resolve package version
id: package_version
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Check
run: pnpm check
- name: Build
run: pnpm build
- name: Build Control UI
run: node scripts/ui.js build
- name: Verify release contents
run: pnpm release:check
- name: Swift build
run: swift build --package-path apps/macos --configuration release
- name: Swift test
run: swift test --package-path apps/macos --parallel
- name: Package macOS release with ad-hoc signing
env:
APP_VERSION: ${{ steps.package_version.outputs.value }}
BUNDLE_ID: ai.openclaw.mac
BUILD_CONFIG: release
CODESIGN_TIMESTAMP: "off"
SIGN_IDENTITY: "-"
SKIP_NOTARIZE: "1"
SKIP_PNPM_INSTALL: "1"
SKIP_TSC: "1"
SKIP_UI_BUILD: "1"
SPARKLE_FEED_URL: ${{ env.SPARKLE_FEED_URL }}
run: scripts/package-mac-dist.sh
publish_macos_release:
needs: [preflight_macos_release]
if: ${{ !inputs.preflight_only }}
runs-on: macos-latest
environment: mac-release
concurrency:
# Stable releases all derive the same shared appcast.xml; serialize those
# runs so each artifact starts from the latest stable feed snapshot.
group: macos-release-publish-${{ contains(inputs.tag, '-beta.') && inputs.tag || 'stable-feed' }}
cancel-in-progress: false
permissions:
contents: write
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout selected tag
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Ensure matching GitHub release exists
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
- name: Resolve package version
id: package_version
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Determine release channel
id: release_channel
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ "$RELEASE_TAG" == *-beta.* ]]; then
echo "is_beta=true" >> "$GITHUB_OUTPUT"
else
echo "is_beta=false" >> "$GITHUB_OUTPUT"
fi
- name: Import Developer ID certificate
env:
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
run: |
set -euo pipefail
CERT_PATH="$RUNNER_TEMP/openclaw-macos-release.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/openclaw-release.keychain-db"
KEYCHAIN_PASSWORD="$(openssl rand -hex 32)"
echo "::add-mask::$KEYCHAIN_PASSWORD"
export CERT_PATH MACOS_DEVELOPER_ID_P12_BASE64
python3 - <<'PY'
import base64
import os
from pathlib import Path
Path(os.environ["CERT_PATH"]).write_bytes(
base64.b64decode(os.environ["MACOS_DEVELOPER_ID_P12_BASE64"])
)
PY
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" \
-k "$KEYCHAIN_PATH" \
-P "$MACOS_DEVELOPER_ID_P12_PASSWORD" \
-T /usr/bin/codesign \
-T /usr/bin/security
EXISTING_KEYCHAINS="$(security list-keychains -d user | tr -d '"')"
security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS
security default-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Resolve signing identity
run: |
set -euo pipefail
SIGN_IDENTITY="$(security find-identity -p codesigning -v "$KEYCHAIN_PATH" 2>/dev/null | awk -F'\"' '/Developer ID Application/ { print $2; exit }')"
if [[ -z "${SIGN_IDENTITY}" ]]; then
echo "Developer ID Application identity not found in imported keychain." >&2
exit 1
fi
echo "SIGN_IDENTITY=$SIGN_IDENTITY" >> "$GITHUB_ENV"
- name: Write notary and Sparkle key files
env:
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
set -euo pipefail
NOTARYTOOL_KEY_PATH="$RUNNER_TEMP/openclaw-notary.p8"
SPARKLE_PRIVATE_KEY_PATH="$RUNNER_TEMP/openclaw-sparkle-ed25519.pem"
export NOTARYTOOL_KEY_PATH SPARKLE_PRIVATE_KEY_PATH
python3 - <<'PY'
import os
from pathlib import Path
def write_secret(path_env: str, value_env: str) -> None:
value = os.environ[value_env].replace("\\n", "\n")
Path(os.environ[path_env]).write_text(value, encoding="utf-8")
write_secret("NOTARYTOOL_KEY_PATH", "APP_STORE_CONNECT_API_KEY_P8")
write_secret("SPARKLE_PRIVATE_KEY_PATH", "SPARKLE_PRIVATE_KEY")
PY
echo "NOTARYTOOL_KEY=$NOTARYTOOL_KEY_PATH" >> "$GITHUB_ENV"
echo "NOTARYTOOL_KEY_ID=$APP_STORE_CONNECT_KEY_ID" >> "$GITHUB_ENV"
echo "NOTARYTOOL_ISSUER=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
echo "SPARKLE_PRIVATE_KEY_FILE=$SPARKLE_PRIVATE_KEY_PATH" >> "$GITHUB_ENV"
- name: Build, sign, notarize, and package macOS release
env:
APP_VERSION: ${{ steps.package_version.outputs.value }}
BUNDLE_ID: ai.openclaw.mac
BUILD_CONFIG: release
SIGN_IDENTITY: ${{ env.SIGN_IDENTITY }}
SKIP_PNPM_INSTALL: "1"
SPARKLE_FEED_URL: ${{ env.SPARKLE_FEED_URL }}
run: scripts/package-mac-dist.sh
- name: Checkout main branch for appcast seed
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
uses: actions/checkout@v6
with:
path: openclaw-main
ref: main
fetch-depth: 0
- name: Seed appcast from main
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
run: |
set -euo pipefail
APPCAST_SOURCE="openclaw-main/appcast.xml"
if [[ -f "$APPCAST_SOURCE" ]]; then
cp "$APPCAST_SOURCE" appcast.xml
else
echo "No existing appcast at $APPCAST_SOURCE; generating a fresh feed."
fi
- name: Generate signed appcast artifact
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
env:
SPARKLE_DOWNLOAD_URL_PREFIX: https://github.com/openclaw/openclaw/releases/download/${{ inputs.tag }}/
SPARKLE_RELEASE_VERSION: ${{ steps.package_version.outputs.value }}
run: scripts/make_appcast.sh "dist/OpenClaw-${{ steps.package_version.outputs.value }}.zip" "${{ env.SPARKLE_FEED_URL }}"
- name: Upload stable appcast artifact
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
uses: actions/upload-artifact@v7
with:
name: macos-appcast-${{ inputs.tag }}
path: appcast.xml
if-no-files-found: error
- name: Skip shared appcast for beta releases
if: ${{ steps.release_channel.outputs.is_beta == 'true' }}
run: echo "Beta release detected; skip shared production appcast artifact generation."
- name: Upload macOS assets to GitHub release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
VERSION: ${{ steps.package_version.outputs.value }}
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" \
"dist/OpenClaw-$VERSION.zip" \
"dist/OpenClaw-$VERSION.dmg" \
"dist/OpenClaw-$VERSION.dSYM.zip" \
--clobber \
--repo "$GITHUB_REPOSITORY"
- name: Clean up signing keychain
if: always()
run: |
if [[ -n "${KEYCHAIN_PATH:-}" ]]; then
security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true
fi
+65 -111
View File
@@ -1,15 +1,17 @@
name: OpenClaw NPM Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Release tag to publish (for example v2026.3.22, v2026.3.22-beta.1, or fallback v2026.3.22-1)
required: true
type: string
preflight_only:
description: Run validation/build only and skip the gated publish job
required: true
default: false
type: boolean
concurrency:
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
@@ -21,117 +23,10 @@ env:
PNPM_VERSION: "10.23.0"
jobs:
preview_openclaw_npm:
if: github.event_name == 'push'
preflight_openclaw_npm:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Print release plan
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
TAG_KIND="fallback correction"
else
TAG_KIND="standard"
fi
echo "Release plan for ${RELEASE_TAG}:"
echo "Resolved release SHA: ${RELEASE_SHA}"
echo "Resolved package version: ${PACKAGE_VERSION}"
echo "Resolved tag kind: ${TAG_KIND}"
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
fi
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
echo "Would run: pnpm check"
echo "Would run: pnpm build"
echo "Would run: pnpm release:check"
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ github.ref_name }}
RELEASE_MAIN_REF: origin/main
run: |
set -euxo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
# Fetch the full main ref so merge-base ancestry checks keep working
# for older tagged commits that are still contained in main.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euxo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
IS_CORRECTION_TAG=0
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
IS_CORRECTION_TAG=1
fi
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
exit 0
fi
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
else
echo "Previewing openclaw@${PACKAGE_VERSION}"
fi
- name: Check
run: |
set -euxo pipefail
pnpm check
- name: Build
run: |
set -euxo pipefail
pnpm build
- name: Verify release contents
run: |
set -euxo pipefail
pnpm release:check
- name: Preview publish command
run: bash scripts/openclaw-npm-publish.sh --dry-run
publish_openclaw_npm:
if: github.event_name == 'workflow_dispatch'
# npm trusted publishing + provenance requires a GitHub-hosted runner.
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
id-token: write
steps:
- name: Validate tag input format
env:
@@ -191,5 +86,64 @@ jobs:
- name: Verify release contents
run: pnpm release:check
publish_openclaw_npm:
# npm trusted publishing + provenance requires a GitHub-hosted runner.
needs: [preflight_openclaw_npm]
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
id-token: write
steps:
- name: Validate tag input format
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
echo "Invalid release tag format: ${RELEASE_TAG}"
exit 1
fi
- name: Checkout
uses: actions/checkout@v6
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "false"
use-sticky-disk: "false"
- name: Validate release tag and package metadata
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_MAIN_REF: origin/main
run: |
set -euo pipefail
RELEASE_SHA=$(git rev-parse HEAD)
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
# Fetch the full main ref so merge-base ancestry checks keep working
# for older tagged commits that are still contained in main.
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
pnpm release:openclaw:npm:check
- name: Ensure version is not already published
run: |
set -euo pipefail
PACKAGE_VERSION=$(node -p "require('./package.json').version")
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
exit 1
fi
echo "Publishing openclaw@${PACKAGE_VERSION}"
- name: Publish
run: bash scripts/openclaw-npm-publish.sh --publish
+36
View File
@@ -6,6 +6,35 @@ Docs: https://docs.openclaw.ai
### Breaking
### Changes
### Fixes
- Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
- Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
- Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
- Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
- ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
- ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020.
- Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
- Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
- Config/plugins: treat stale unknown `plugins.allow` ids as warnings instead of fatal config errors, so recovery commands like `plugins install`, `doctor --fix`, and `status` still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.
- Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like `whatsapp` to `plugins.allow`, so `openclaw doctor --fix` no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.
- Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
- Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
- Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.
## 2026.3.22
### Breaking
- Plugins/install: bare `openclaw plugins install <package>` now prefers ClawHub before npm for npm-safe names, and only falls back to npm when ClawHub does not have that package or version. Docs: https://docs.openclaw.ai/tools/clawhub
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Docs: https://docs.openclaw.ai/gateway/doctor and https://docs.openclaw.ai/tools/browser (#47893) Thanks @vincentkoc.
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
@@ -24,6 +53,7 @@ Docs: https://docs.openclaw.ai
- Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz.
- Android/contacts search: escape literal `%` and `_` in contact-name queries so searches like `100%` or `_id` no longer match unrelated contacts through SQL `LIKE` wildcards. (#41891) Thanks @Kaneki-x.
- Gateway/usage: include reset and deleted archived session transcripts in usage totals, session discovery, and archived-only session detail fallback so the Usage view no longer undercounts rotated sessions. (#43215) Thanks @rcrick.
- Channels/inbound debounce: reserve same-conversation timer-backed flush order so a buffered run cannot be overtaken by a newer message for the same key, preventing Telegram reply desync after long debounced turns. Fixes #52982. Thanks @vincentkoc and @osolmaz.
### Changes
@@ -101,11 +131,16 @@ Docs: https://docs.openclaw.ai
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
- Docs/plugins: add the community wecom plugin listing to the docs catalog. (#29905) Thanks @sliverp.
- Telegram/message tool: add `asDocument` as a user-facing alias for `forceDocument` on image and GIF sends, while preserving explicit `forceDocument` precedence when both flags are present. (#52461) Thanks @bakhtiersizhaev.
### Fixes
- Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
- Plugins/DeepSeek: refactor the bundled DeepSeek provider onto the shared single-provider plugin entry, move its coverage into the extension test lane, and keep bundled auth env-var metadata on the generated manifest path. (#48762) Thanks @07akioni.
- Web tools/search provider lists: keep onboarding, configure, and docs provider lists alphabetical while preserving the separate runtime auto-detect precedence used for credential-based provider selection.
- Media/Windows security: block remote-host `file://` media URLs and UNC/network paths before local filesystem resolution in core media loading and adjacent prompt/sandbox attachment seams, so the next release no longer allows structured local-media inputs to trigger outbound SMB credential handshakes on Windows. Thanks @RacerZ-fighting for reporting.
- Release/npm packaging: keep previously released bundled plugins and Control UI assets in published `openclaw` npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
- Plugins/Matrix: avoid duplicate `resolveMatrixAccountStringValues` runtime-api exports under Jiti so bundled Matrix installs no longer crash at startup with `Cannot redefine property: resolveMatrixAccountStringValues`. Fixes #52909 and #52891. Thanks @vincentkoc.
- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting.
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
@@ -321,6 +356,7 @@ Docs: https://docs.openclaw.ai
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
- Telegram/Mattermost message tool: keep plugin button schemas optional in isolated and cron sessions so plain sends do not fail validation when no current channel is active. (#52589) Thanks @tylerliu612.
- Release/npm publish: fail the npm release check when `dist/control-ui/index.html` is missing from the packed tarball, so broken Control UI asset releases are blocked before publish. Fixes #52808. (#52852) Thanks @kevinheinrichs.
## 2026.3.13
+2 -2
View File
@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026032200
versionName = "2026.3.22"
versionCode = 2026032300
versionName = "2026.3.23"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
+3 -3
View File
@@ -1,8 +1,8 @@
// Shared iOS version defaults.
// Generated overrides live in build/Version.xcconfig (git-ignored).
OPENCLAW_GATEWAY_VERSION = 2026.3.22
OPENCLAW_MARKETING_VERSION = 2026.3.22
OPENCLAW_BUILD_VERSION = 202603220
OPENCLAW_GATEWAY_VERSION = 2026.3.23-beta.1
OPENCLAW_MARKETING_VERSION = 2026.3.23
OPENCLAW_BUILD_VERSION = 202603230
#include? "../build/Version.xcconfig"
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.22</string>
<string>2026.3.23</string>
<key>CFBundleVersion</key>
<string>202603220</string>
<string>202603230</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>
+121
View File
@@ -47450,6 +47450,127 @@
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/deepseek-provider",
"help": "OpenClaw DeepSeek provider plugin (plugin: deepseek)",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.config",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "@openclaw/deepseek-provider Config",
"help": "Plugin-defined config payload for deepseek.",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.enabled",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Enable @openclaw/deepseek-provider",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.hooks",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Hook Policy",
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.hooks.allowPromptInjection",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Prompt Injection Hooks",
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.subagent",
"kind": "plugin",
"type": "object",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Plugin Subagent Policy",
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.subagent.allowedModels",
"kind": "plugin",
"type": "array",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Plugin Subagent Allowed Models",
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
"hasChildren": true
},
{
"path": "plugins.entries.deepseek.subagent.allowedModels.*",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "plugins.entries.deepseek.subagent.allowModelOverride",
"kind": "plugin",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"access"
],
"label": "Allow Plugin Subagent Model Override",
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
"hasChildren": false
},
{
"path": "plugins.entries.device-pair",
"kind": "plugin",
+10 -1
View File
@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5619}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5628}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -4188,6 +4188,15 @@
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepseek-provider","help":"OpenClaw DeepSeek provider plugin (plugin: deepseek)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepseek-provider Config","help":"Plugin-defined config payload for deepseek.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/deepseek-provider","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.device-pair","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing","help":"Generate setup codes and approve device pairing requests. (plugin: device-pair)","hasChildren":true}
{"recordType":"path","path":"plugins.entries.device-pair.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing Config","help":"Plugin-defined config payload for device-pair.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.device-pair.config.publicUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway URL","help":"Public WebSocket URL used for /pair setup codes (ws/wss or http/https).","hasChildren":false}
+1 -1
View File
@@ -86,7 +86,7 @@ Payload:
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `channel` optional (string): The messaging channel for delivery. Core channels: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `irc`, `googlechat`, `line`. Extension channels (plugins): `msteams`, `mattermost`, and others. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `model` optional (string): Model override (e.g., `anthropic/claude-sonnet-4-6` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
+2 -1
View File
@@ -26,11 +26,12 @@ Text is supported everywhere; media and reactions vary by channel.
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
- [Voice Call](/plugins/voice-call) — Telephony via Plivo or Twilio (plugin, installed separately).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
+9 -2
View File
@@ -113,7 +113,9 @@ openclaw [--dev] [--profile <name>] <command>
audit
secrets
reload
migrate
audit
configure
apply
reset
uninstall
update
@@ -132,16 +134,21 @@ openclaw [--dev] [--profile <name>] <command>
check
plugins
list
info
inspect
install
uninstall
update
enable
disable
doctor
marketplace list
memory
status
index
search
message
send
broadcast
agent
agents
list
+8 -7
View File
@@ -950,6 +950,7 @@
"pages": [
"install/updating",
"install/migrating",
"install/migrating-matrix",
"install/uninstall",
"install/development-channels"
]
@@ -979,8 +980,8 @@
"channels/nextcloud-talk",
"channels/nostr",
"channels/signal",
"channels/synology-chat",
"channels/slack",
"channels/synology-chat",
"channels/telegram",
"channels/tlon",
"channels/twitch",
@@ -1180,25 +1181,26 @@
"pages": [
"providers/anthropic",
"providers/bedrock",
"providers/cloudflare-ai-gateway",
"providers/claude-max-api-proxy",
"providers/cloudflare-ai-gateway",
"providers/deepgram",
"providers/deepseek",
"providers/github-copilot",
"providers/glm",
"providers/google",
"providers/groq",
"providers/huggingface",
"providers/kilocode",
"providers/litellm",
"providers/glm",
"providers/minimax",
"providers/mistral",
"providers/modelstudio",
"providers/moonshot",
"providers/mistral",
"providers/nvidia",
"providers/ollama",
"providers/openai",
"providers/opencode-go",
"providers/opencode",
"providers/opencode-go",
"providers/openrouter",
"providers/perplexity-provider",
"providers/qianfan",
@@ -1206,8 +1208,8 @@
"providers/sglang",
"providers/synthetic",
"providers/together",
"providers/vercel-ai-gateway",
"providers/venice",
"providers/vercel-ai-gateway",
"providers/vllm",
"providers/volcengine",
"providers/xai",
@@ -1744,7 +1746,6 @@
{
"group": "扩展",
"pages": [
"zh-CN/plugins/architecture",
"zh-CN/plugins/voice-call",
"zh-CN/plugins/zalouser",
"zh-CN/plugins/manifest",
-3
View File
@@ -27,9 +27,6 @@ Always import from a specific subpath:
```typescript
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
// Deprecated — will be removed in the next major release
import { definePluginEntry } from "openclaw/plugin-sdk";
```
Each subpath is a small, self-contained module. This keeps startup fast and
+53
View File
@@ -0,0 +1,53 @@
---
summary: "DeepSeek setup (auth + model selection)"
read_when:
- You want to use DeepSeek with OpenClaw
- You need the API key env var or CLI auth choice
---
# DeepSeek
[DeepSeek](https://www.deepseek.com) provides powerful AI models with an OpenAI-compatible API.
- Provider: `deepseek`
- Auth: `DEEPSEEK_API_KEY`
- API: OpenAI-compatible
## Quick start
Set the API key (recommended: store it for the Gateway):
```bash
openclaw onboard --auth-choice deepseek-api-key
```
This will prompt for your API key and set `deepseek/deepseek-chat` as the default model.
## Non-interactive example
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice deepseek-api-key \
--deepseek-api-key "$DEEPSEEK_API_KEY" \
--skip-health \
--accept-risk
```
## Environment note
If the Gateway runs as a daemon (launchd/systemd), make sure `DEEPSEEK_API_KEY`
is available to that process (for example, in `~/.openclaw/.env` or via
`env.shellEnv`).
## Available models
| Model ID | Name | Type | Context |
| ------------------- | ------------------------ | --------- | ------- |
| `deepseek-chat` | DeepSeek Chat (V3.2) | General | 128K |
| `deepseek-reasoner` | DeepSeek Reasoner (V3.2) | Reasoning | 128K |
- **deepseek-chat** corresponds to DeepSeek-V3.2 in non-thinking mode.
- **deepseek-reasoner** corresponds to DeepSeek-V3.2 in thinking mode with chain-of-thought reasoning.
Get your API key at [platform.deepseek.com](https://platform.deepseek.com/api_keys).
+6 -2
View File
@@ -29,6 +29,8 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Amazon Bedrock](/providers/bedrock)
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [DeepSeek](/providers/deepseek)
- [GitHub Copilot](/providers/github-copilot)
- [GLM models](/providers/glm)
- [Google (Gemini)](/providers/google)
- [Groq (LPU inference)](/providers/groq)
@@ -42,15 +44,17 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [NVIDIA](/providers/nvidia)
- [Ollama (cloud + local models)](/providers/ollama)
- [OpenAI (API + Codex)](/providers/openai)
- [OpenCode (Zen + Go)](/providers/opencode)
- [OpenCode](/providers/opencode)
- [OpenCode Go](/providers/opencode-go)
- [OpenRouter](/providers/openrouter)
- [Perplexity (web search)](/providers/perplexity-provider)
- [Qianfan](/providers/qianfan)
- [Qwen (OAuth)](/providers/qwen)
- [SGLang (local models)](/providers/sglang)
- [Synthetic](/providers/synthetic)
- [Together AI](/providers/together)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [vLLM (local models)](/providers/vllm)
- [Volcengine (Doubao)](/providers/volcengine)
- [xAI](/providers/xai)
+10 -1
View File
@@ -23,7 +23,7 @@ OpenClaw has three public release lanes:
- Do not zero-pad month or day
- `latest` means the current stable npm release
- `beta` means the current prerelease npm release
- Beta releases may ship before the macOS app catches up
- Every OpenClaw release ships the npm package and macOS app together
## Release cadence
@@ -32,6 +32,15 @@ OpenClaw has three public release lanes:
- Detailed release procedure, approvals, credentials, and recovery notes are
maintainer-only
## Release preflight
- Run `pnpm release:check` before every tagged release
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
(or the matching beta/correction tag) before approval
- npm release preflight fails closed unless the tarball includes both
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
so we do not ship an empty browser dashboard again
## Public references
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
+12 -12
View File
@@ -109,18 +109,18 @@ Per-agent override: `agents.list[].tools.profile`.
Use `group:*` shorthands in allow/deny lists:
| Group | Tools |
| ------------------ | ------------------------------------------------------------------------------ |
| `group:runtime` | exec, bash, process |
| `group:fs` | read, write, edit, apply_patch |
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status |
| `group:memory` | memory_search, memory_get |
| `group:web` | web_search, web_fetch |
| `group:ui` | browser, canvas |
| `group:automation` | cron, gateway |
| `group:messaging` | message |
| `group:nodes` | nodes |
| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
| Group | Tools |
| ------------------ | --------------------------------------------------------------------------------------------------------- |
| `group:runtime` | exec, bash, process |
| `group:fs` | read, write, edit, apply_patch |
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, sessions_yield, subagents, session_status |
| `group:memory` | memory_search, memory_get |
| `group:web` | web_search, web_fetch |
| `group:ui` | browser, canvas |
| `group:automation` | cron, gateway |
| `group:messaging` | message |
| `group:nodes` | nodes |
| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
### Provider-specific restrictions
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveProviderPluginChoice } from "../../src/plugins/provider-wizard.js";
import { registerSingleProviderPlugin } from "../../test/helpers/extensions/plugin-registration.js";
import deepseekPlugin from "./index.js";
describe("deepseek provider plugin", () => {
it("registers DeepSeek with api-key auth wizard metadata", () => {
const provider = registerSingleProviderPlugin(deepseekPlugin);
const resolved = resolveProviderPluginChoice({
providers: [provider],
choice: "deepseek-api-key",
});
expect(provider.id).toBe("deepseek");
expect(provider.label).toBe("DeepSeek");
expect(provider.envVars).toEqual(["DEEPSEEK_API_KEY"]);
expect(provider.auth).toHaveLength(1);
expect(resolved).not.toBeNull();
expect(resolved?.provider.id).toBe("deepseek");
expect(resolved?.method.id).toBe("api-key");
});
it("builds the static DeepSeek model catalog", async () => {
const provider = registerSingleProviderPlugin(deepseekPlugin);
expect(provider.catalog).toBeDefined();
const catalog = await provider.catalog!.run({
config: {},
env: {},
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
resolveProviderAuth: () => ({
apiKey: "test-key",
mode: "api_key",
source: "env",
}),
} as never);
expect(catalog && "provider" in catalog).toBe(true);
if (!catalog || !("provider" in catalog)) {
throw new Error("expected single-provider catalog");
}
expect(catalog.provider.api).toBe("openai-completions");
expect(catalog.provider.baseUrl).toBe("https://api.deepseek.com");
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
"deepseek-chat",
"deepseek-reasoner",
]);
expect(
catalog.provider.models?.find((model) => model.id === "deepseek-reasoner")?.reasoning,
).toBe(true);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
import { buildDeepSeekProvider } from "./provider-catalog.js";
const PROVIDER_ID = "deepseek";
export default defineSingleProviderPluginEntry({
id: PROVIDER_ID,
name: "DeepSeek Provider",
description: "Bundled DeepSeek provider plugin",
provider: {
label: "DeepSeek",
docsPath: "/providers/deepseek",
auth: [
{
methodId: "api-key",
label: "DeepSeek API key",
hint: "API key",
optionKey: "deepseekApiKey",
flagName: "--deepseek-api-key",
envVar: "DEEPSEEK_API_KEY",
promptMessage: "Enter DeepSeek API key",
defaultModel: DEEPSEEK_DEFAULT_MODEL_REF,
applyConfig: (cfg) => applyDeepSeekConfig(cfg),
wizard: {
choiceId: "deepseek-api-key",
choiceLabel: "DeepSeek API key",
groupId: "deepseek",
groupLabel: "DeepSeek",
groupHint: "API key",
},
},
],
catalog: {
buildProvider: buildDeepSeekProvider,
},
},
});
+35
View File
@@ -0,0 +1,35 @@
import {
buildDeepSeekModelDefinition,
DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL_CATALOG,
} from "openclaw/plugin-sdk/provider-models";
import {
applyAgentDefaultModelPrimary,
applyProviderConfigWithModelCatalog,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
export const DEEPSEEK_DEFAULT_MODEL_REF = "deepseek/deepseek-chat";
export function applyDeepSeekProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
const models = { ...cfg.agents?.defaults?.models };
models[DEEPSEEK_DEFAULT_MODEL_REF] = {
...models[DEEPSEEK_DEFAULT_MODEL_REF],
alias: models[DEEPSEEK_DEFAULT_MODEL_REF]?.alias ?? "DeepSeek",
};
return applyProviderConfigWithModelCatalog(cfg, {
agentModels: models,
providerId: "deepseek",
api: "openai-completions",
baseUrl: DEEPSEEK_BASE_URL,
catalogModels: DEEPSEEK_MODEL_CATALOG.map(buildDeepSeekModelDefinition),
});
}
export function applyDeepSeekConfig(cfg: OpenClawConfig): OpenClawConfig {
return applyAgentDefaultModelPrimary(
applyDeepSeekProviderConfig(cfg),
DEEPSEEK_DEFAULT_MODEL_REF,
);
}
+27
View File
@@ -0,0 +1,27 @@
{
"id": "deepseek",
"providers": ["deepseek"],
"providerAuthEnvVars": {
"deepseek": ["DEEPSEEK_API_KEY"]
},
"providerAuthChoices": [
{
"provider": "deepseek",
"method": "api-key",
"choiceId": "deepseek-api-key",
"choiceLabel": "DeepSeek API key",
"groupId": "deepseek",
"groupLabel": "DeepSeek",
"groupHint": "API key",
"optionKey": "deepseekApiKey",
"cliFlag": "--deepseek-api-key",
"cliOption": "--deepseek-api-key <key>",
"cliDescription": "DeepSeek API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@openclaw/deepseek-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw DeepSeek provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
+14
View File
@@ -0,0 +1,14 @@
import {
buildDeepSeekModelDefinition,
DEEPSEEK_BASE_URL,
DEEPSEEK_MODEL_CATALOG,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-models";
export function buildDeepSeekProvider(): ModelProviderConfig {
return {
baseUrl: DEEPSEEK_BASE_URL,
api: "openai-completions",
models: DEEPSEEK_MODEL_CATALOG.map(buildDeepSeekModelDefinition),
};
}
+5 -1
View File
@@ -1,9 +1,13 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js";
import { fetchDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";
describe("fetchDiscord", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("formats rate limit payloads without raw JSON", async () => {
const fetcher = withFetchPreconnect(async () =>
jsonResponse(
@@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it, vi } from "vitest";
@@ -56,6 +57,24 @@ describe("discordMessageActions", () => {
expect(discovery?.actions).not.toContain("role-add");
});
it("keeps components optional in the message tool schema", () => {
const discovery = discordMessageActions.describeMessageTool?.({
cfg: {
channels: {
discord: {
token: "Bot token-main",
},
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected discord message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
});
it("extracts send targets for message and thread reply actions", () => {
expect(
discordMessageActions.extractToolSend?.({
+2 -1
View File
@@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import {
createUnionActionGate,
listTokenSourcedAccounts,
@@ -124,7 +125,7 @@ function describeDiscordMessageTool({
capabilities: ["interactive", "components"],
schema: {
properties: {
components: createDiscordMessageToolComponentsSchema(),
components: Type.Optional(createDiscordMessageToolComponentsSchema()),
},
},
};
+10 -3
View File
@@ -1,10 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
import type { ResolvedDiscordAccount } from "./accounts.js";
import { discordPlugin } from "./channel.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { setDiscordRuntime } from "./runtime.js";
let discordPlugin: typeof import("./channel.js").discordPlugin;
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
const probeDiscordMock = vi.hoisted(() => vi.fn());
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
@@ -75,6 +75,13 @@ afterEach(() => {
auditDiscordChannelPermissionsMock.mockReset();
});
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
({ discordPlugin } = await import("./channel.js"));
({ setDiscordRuntime } = await import("./runtime.js"));
});
describe("discordPlugin outbound", () => {
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
+21 -12
View File
@@ -1,16 +1,25 @@
import { MessageFlags } from "discord-api-types/v10";
import { beforeEach, describe, expect, it } from "vitest";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} from "./components-registry.js";
import {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
readDiscordComponentSpec,
} from "./components.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
let resolveDiscordComponentEntry: typeof import("./components-registry.js").resolveDiscordComponentEntry;
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage;
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
beforeEach(async () => {
vi.resetModules();
({
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} = await import("./components-registry.js"));
({ buildDiscordComponentMessage, buildDiscordComponentMessageFlags, readDiscordComponentSpec } =
await import("./components.js"));
});
describe("discord components", () => {
it("builds v2 containers with modal trigger", () => {
+5 -1
View File
@@ -26,6 +26,10 @@ import {
} from "@buape/carbon";
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
// Some test-only module graphs partially mock `@buape/carbon` and can drop `Modal`.
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
const ModalBase: typeof Modal = (Modal ?? class {}) as typeof Modal;
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
@@ -1126,7 +1130,7 @@ export function buildDiscordComponentMessageFlags(
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
}
export class DiscordFormModal extends Modal {
export class DiscordFormModal extends ModalBase {
title: string;
customId: string;
components: Array<Label | TextDisplay>;
+10 -4
View File
@@ -1,12 +1,12 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../src/globals.js", () => ({
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: vi.fn(),
}));
import { logVerbose } from "../../../src/globals.js";
import { attachDiscordGatewayLogging } from "./gateway-logging.js";
let logVerbose: typeof import("openclaw/plugin-sdk/runtime-env").logVerbose;
let attachDiscordGatewayLogging: typeof import("./gateway-logging.js").attachDiscordGatewayLogging;
const makeRuntime = () => ({
log: vi.fn(),
@@ -15,6 +15,12 @@ const makeRuntime = () => ({
});
describe("attachDiscordGatewayLogging", () => {
beforeEach(async () => {
vi.resetModules();
({ logVerbose } = await import("openclaw/plugin-sdk/runtime-env"));
({ attachDiscordGatewayLogging } = await import("./gateway-logging.js"));
});
afterEach(() => {
vi.clearAllMocks();
});
+21
View File
@@ -61,6 +61,11 @@ function createAutoThreadMentionContext() {
return { guildInfo, channelConfig };
}
beforeEach(() => {
vi.useRealTimers();
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
});
describe("registerDiscordListener", () => {
class FakeListener {}
@@ -892,6 +897,22 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({
})),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: enqueueSystemEventSpy,
};
});
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
return {
...actual,
resolveAgentRoute: resolveAgentRouteMock,
};
});
vi.mock("../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: enqueueSystemEventSpy,
}));
@@ -1,5 +1,5 @@
import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { MessageType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
dispatchMock,
@@ -9,13 +9,19 @@ import {
updateLastRouteMock,
upsertPairingRequestMock,
} from "./monitor.tool-result.test-harness.js";
import { createDiscordMessageHandler } from "./monitor/message-handler.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
type Config = ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
let ChannelType: typeof import("@buape/carbon").ChannelType;
let createDiscordMessageHandler: typeof import("./monitor/message-handler.js").createDiscordMessageHandler;
let __resetDiscordChannelInfoCacheForTest: typeof import("./monitor/message-utils.js").__resetDiscordChannelInfoCacheForTest;
let createNoopThreadBindingManager: typeof import("./monitor/thread-bindings.js").createNoopThreadBindingManager;
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ ChannelType } = await import("@buape/carbon"));
({ createDiscordMessageHandler } = await import("./monitor/message-handler.js"));
({ __resetDiscordChannelInfoCacheForTest } = await import("./monitor/message-utils.js"));
({ createNoopThreadBindingManager } = await import("./monitor/thread-bindings.js"));
__resetDiscordChannelInfoCacheForTest();
sendMock.mockClear().mockResolvedValue(undefined);
updateLastRouteMock.mockClear();
@@ -1,14 +1,28 @@
import { describe, expect, it } from "vitest";
import { buildDiscordComponentCustomId, buildDiscordModalCustomId } from "../components.js";
import {
createDiscordComponentButton,
createDiscordComponentChannelSelect,
createDiscordComponentMentionableSelect,
createDiscordComponentModal,
createDiscordComponentRoleSelect,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} from "./agent-components.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
let createDiscordComponentButton: typeof import("./agent-components.js").createDiscordComponentButton;
let createDiscordComponentChannelSelect: typeof import("./agent-components.js").createDiscordComponentChannelSelect;
let createDiscordComponentMentionableSelect: typeof import("./agent-components.js").createDiscordComponentMentionableSelect;
let createDiscordComponentModal: typeof import("./agent-components.js").createDiscordComponentModal;
let createDiscordComponentRoleSelect: typeof import("./agent-components.js").createDiscordComponentRoleSelect;
let createDiscordComponentStringSelect: typeof import("./agent-components.js").createDiscordComponentStringSelect;
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
beforeEach(async () => {
vi.resetModules();
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
({
createDiscordComponentButton,
createDiscordComponentChannelSelect,
createDiscordComponentMentionableSelect,
createDiscordComponentModal,
createDiscordComponentRoleSelect,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} = await import("./agent-components.js"));
});
type WildcardComponent = {
customId: string;
@@ -6,15 +6,6 @@ import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
import {
buildExecApprovalCustomId,
extractDiscordChannelId,
parseExecApprovalData,
type ExecApprovalRequest,
DiscordExecApprovalHandler,
ExecApprovalButton,
type ExecApprovalButtonContext,
} from "./exec-approvals.js";
const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json");
@@ -59,6 +50,7 @@ const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true }))
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
vi.mock("../send.shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.shared.js")>();
@@ -75,8 +67,12 @@ vi.mock("../send.shared.js", async (importOriginal) => {
};
});
vi.mock("../../../../src/gateway/client.js", () => ({
GatewayClient: class {
vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/gateway-runtime")>();
type CreateOperatorApprovalsGatewayClientParams = Parameters<
typeof actual.createOperatorApprovalsGatewayClient
>[0];
class MockGatewayClient {
private params: Record<string, unknown>;
constructor(params: Record<string, unknown>) {
this.params = params;
@@ -92,18 +88,70 @@ vi.mock("../../../../src/gateway/client.js", () => ({
async request() {
return gatewayClientRequests();
}
},
}));
}
return {
...actual,
GatewayClient: MockGatewayClient,
createOperatorApprovalsGatewayClient: async (
params: CreateOperatorApprovalsGatewayClientParams,
) => {
mockCreateOperatorApprovalsGatewayClient(params);
const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim();
const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789";
const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined;
const auth = await mockResolveGatewayConnectionAuth({
config: params.config,
env: process.env,
...(urlOverrideSource
? {
urlOverride: gatewayUrl,
urlOverrideSource,
}
: {}),
});
return new MockGatewayClient({
url: gatewayUrl,
token: auth?.token,
password: auth?.password,
clientName: "gateway-client",
clientDisplayName: params.clientDisplayName,
mode: "backend",
scopes: ["operator.approvals"],
onEvent: params.onEvent,
onHelloOk: params.onHelloOk,
onConnectError: params.onConnectError,
onClose: params.onClose,
});
},
};
});
vi.mock("../../../../src/gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
}));
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
return {
...actual,
logDebug: vi.fn(),
logError: vi.fn(),
};
});
vi.mock("../../../../src/logger.js", () => ({
logDebug: vi.fn(),
logError: vi.fn(),
}));
let buildExecApprovalCustomId: typeof import("./exec-approvals.js").buildExecApprovalCustomId;
let extractDiscordChannelId: typeof import("./exec-approvals.js").extractDiscordChannelId;
let parseExecApprovalData: typeof import("./exec-approvals.js").parseExecApprovalData;
let DiscordExecApprovalHandler: typeof import("./exec-approvals.js").DiscordExecApprovalHandler;
let ExecApprovalButton: typeof import("./exec-approvals.js").ExecApprovalButton;
type DiscordExecApprovalHandlerInstance = InstanceType<
typeof import("./exec-approvals.js").DiscordExecApprovalHandler
>;
type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest;
type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function createHandler(config: DiscordExecApprovalConfig, accountId = "default") {
@@ -143,7 +191,7 @@ function mockSuccessfulDmDelivery(params?: {
}
async function expectGatewayAuthStart(params: {
handler: DiscordExecApprovalHandler;
handler: DiscordExecApprovalHandlerInstance;
expectedUrl: string;
expectedSource: "cli" | "env";
expectedToken?: string;
@@ -181,11 +229,13 @@ type ExecApprovalHandlerInternals = {
handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
};
function getHandlerInternals(handler: DiscordExecApprovalHandler): ExecApprovalHandlerInternals {
function getHandlerInternals(
handler: DiscordExecApprovalHandlerInstance,
): ExecApprovalHandlerInternals {
return handler as unknown as ExecApprovalHandlerInternals;
}
function clearPendingTimeouts(handler: DiscordExecApprovalHandler) {
function clearPendingTimeouts(handler: DiscordExecApprovalHandlerInstance) {
const internals = getHandlerInternals(handler);
for (const pending of internals.pending.values()) {
clearTimeout(pending.timeoutId);
@@ -220,6 +270,18 @@ beforeEach(() => {
gatewayClientRequests.mockReset();
gatewayClientRequests.mockResolvedValue({ ok: true });
gatewayClientParams.length = 0;
mockCreateOperatorApprovalsGatewayClient.mockReset();
});
beforeEach(async () => {
vi.resetModules();
({
buildExecApprovalCustomId,
extractDiscordChannelId,
parseExecApprovalData,
DiscordExecApprovalHandler,
ExecApprovalButton,
} = await import("./exec-approvals.js"));
});
// ─── buildExecApprovalCustomId ────────────────────────────────────────────────
@@ -1,5 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { DiscordMessageListener } from "./listeners.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let DiscordMessageListener: typeof import("./listeners.js").DiscordMessageListener;
beforeEach(async () => {
vi.resetModules();
({ DiscordMessageListener } = await import("./listeners.js"));
});
function createLogger() {
return {
@@ -10,11 +10,6 @@ import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../../../src/infra/outbound/session-binding-service.js";
import {
preflightDiscordMessage,
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} from "./message-handler.preflight.js";
import {
createDiscordMessage,
createDiscordPreflightArgs,
@@ -25,10 +20,22 @@ import {
type DiscordConfig,
type DiscordMessageEvent,
} from "./message-handler.preflight.test-helpers.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
let preflightDiscordMessage: typeof import("./message-handler.preflight.js").preflightDiscordMessage;
let resolvePreflightMentionRequirement: typeof import("./message-handler.preflight.js").resolvePreflightMentionRequirement;
let shouldIgnoreBoundThreadWebhookMessage: typeof import("./message-handler.preflight.js").shouldIgnoreBoundThreadWebhookMessage;
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
beforeEach(async () => {
vi.resetModules();
({
preflightDiscordMessage,
resolvePreflightMentionRequirement,
shouldIgnoreBoundThreadWebhookMessage,
} = await import("./message-handler.preflight.js"));
({ __testing: threadBindingTesting, createThreadBindingManager } =
await import("./thread-bindings.js"));
});
function createThreadBinding(
overrides?: Partial<
@@ -1,13 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EMOJIS } from "../../../../src/channels/status-reactions.js";
import {
createBaseDiscordMessageContext,
createDiscordDirectMessageContextOverrides,
} from "./message-handler.test-harness.js";
import {
__testing as threadBindingTesting,
createThreadBindingManager,
} from "./thread-bindings.js";
const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn(async () => {}),
@@ -66,6 +58,11 @@ const configSessionsMocks = vi.hoisted(() => ({
}));
const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt;
const resolveStorePath = configSessionsMocks.resolveStorePath;
let createBaseDiscordMessageContext: typeof import("./message-handler.test-harness.js").createBaseDiscordMessageContext;
let createDiscordDirectMessageContextOverrides: typeof import("./message-handler.test-harness.js").createDiscordDirectMessageContextOverrides;
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
let processDiscordMessage: typeof import("./message-handler.process.js").processDiscordMessage;
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
@@ -89,46 +86,52 @@ vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
}));
vi.mock("../../../../src/auto-reply/dispatch.js", () => ({
dispatchInboundMessage,
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
dispatchInboundMessage,
createReplyDispatcherWithTyping: vi.fn(
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
dispatcher: {
sendToolResult: vi.fn(() => true),
sendBlockReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "block" });
return true;
}),
sendFinalReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "final" });
return true;
}),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
}),
),
};
});
vi.mock("../../../../src/auto-reply/reply/reply-dispatcher.js", () => ({
createReplyDispatcherWithTyping: vi.fn(
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
dispatcher: {
sendToolResult: vi.fn(() => true),
sendBlockReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "block" });
return true;
}),
sendFinalReply: vi.fn((payload: unknown) => {
void opts.deliver(payload as never, { kind: "final" });
return true;
}),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
},
replyOptions: {},
markDispatchIdle: vi.fn(),
markRunComplete: vi.fn(),
}),
),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
recordInboundSession,
};
});
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession,
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
resolveStorePath: configSessionsMocks.resolveStorePath,
};
});
vi.mock("../../../../src/config/sessions.js", () => ({
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
resolveStorePath: configSessionsMocks.resolveStorePath,
}));
const { processDiscordMessage } = await import("./message-handler.process.js");
const createBaseContext = createBaseDiscordMessageContext;
const BASE_CHANNEL_ROUTE = {
agentId: "main",
channel: "discord",
@@ -137,6 +140,18 @@ const BASE_CHANNEL_ROUTE = {
mainSessionKey: "agent:main:main",
} as const;
async function createBaseContext(
...args: Parameters<typeof createBaseDiscordMessageContext>
): Promise<Awaited<ReturnType<typeof createBaseDiscordMessageContext>>> {
return await createBaseDiscordMessageContext(...args);
}
function createDirectMessageContextOverrides(
...args: Parameters<typeof createDiscordDirectMessageContextOverrides>
): ReturnType<typeof createDiscordDirectMessageContextOverrides> {
return createDiscordDirectMessageContextOverrides(...args);
}
function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boolean }) {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendBlockReply(payload);
@@ -154,8 +169,14 @@ async function processStreamOffDiscordMessage() {
await processDiscordMessage(ctx as any);
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.useRealTimers();
({ createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
await import("./message-handler.test-harness.js"));
({ __testing: threadBindingTesting, createThreadBindingManager } =
await import("./thread-bindings.js"));
({ processDiscordMessage } = await import("./message-handler.process.js"));
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
editMessageDiscord.mockClear();
@@ -418,7 +439,7 @@ describe("processDiscordMessage ack reactions", () => {
describe("processDiscordMessage session routing", () => {
it("stores DM lastRoute with user target for direct-session continuity", async () => {
const ctx = await createBaseContext({
...createDiscordDirectMessageContextOverrides(),
...createDirectMessageContextOverrides(),
message: {
id: "m1",
channelId: "dm1",
@@ -1,13 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import {
createDiscordMessageHandler,
preflightDiscordMessageMock,
processDiscordMessageMock,
} from "./message-handler.module-test-helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createDiscordHandlerParams,
createDiscordPreflightContext,
} from "./message-handler.test-helpers.js";
let createDiscordMessageHandler: typeof import("./message-handler.module-test-helpers.js").createDiscordMessageHandler;
let preflightDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").preflightDiscordMessageMock;
let processDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").processDiscordMessageMock;
const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn());
type SetStatusFn = (patch: Record<string, unknown>) => void;
@@ -86,6 +84,12 @@ async function createLifecycleStopScenario(params: {
}
describe("createDiscordMessageHandler queue behavior", () => {
beforeEach(async () => {
vi.resetModules();
({ createDiscordMessageHandler, preflightDiscordMessageMock, processDiscordMessageMock } =
await import("./message-handler.module-test-helpers.js"));
});
it("resets busy counters when the handler is created", () => {
preflightDiscordMessageMock.mockReset();
processDiscordMessageMock.mockReset();
@@ -21,14 +21,24 @@ vi.mock("../../../../src/globals.js", () => ({
logVerbose: () => {},
}));
const {
__resetDiscordChannelInfoCacheForTest,
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
resolveForwardedMediaList,
resolveMediaList,
} = await import("./message-utils.js");
let __resetDiscordChannelInfoCacheForTest: typeof import("./message-utils.js").__resetDiscordChannelInfoCacheForTest;
let resolveDiscordChannelInfo: typeof import("./message-utils.js").resolveDiscordChannelInfo;
let resolveDiscordMessageChannelId: typeof import("./message-utils.js").resolveDiscordMessageChannelId;
let resolveDiscordMessageText: typeof import("./message-utils.js").resolveDiscordMessageText;
let resolveForwardedMediaList: typeof import("./message-utils.js").resolveForwardedMediaList;
let resolveMediaList: typeof import("./message-utils.js").resolveMediaList;
beforeEach(async () => {
vi.resetModules();
({
__resetDiscordChannelInfoCacheForTest,
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
resolveForwardedMediaList,
resolveMediaList,
} = await import("./message-utils.js"));
});
function asMessage(payload: Record<string, unknown>): Message {
return payload as unknown as Message;
@@ -1,10 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { listNativeCommandSpecs } from "../../../../src/auto-reply/commands-registry.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager;
function createNativeCommand(name: string): ReturnType<typeof createDiscordNativeCommand> {
function createNativeCommand(
name: string,
): ReturnType<typeof import("./native-command.js").createDiscordNativeCommand> {
const command = listNativeCommandSpecs({ provider: "discord" }).find(
(entry) => entry.name === name,
);
@@ -24,17 +26,19 @@ function createNativeCommand(name: string): ReturnType<typeof createDiscordNativ
});
}
type CommandOption = NonNullable<ReturnType<typeof createDiscordNativeCommand>["options"]>[number];
type CommandOption = NonNullable<
ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>["options"]
>[number];
function findOption(
command: ReturnType<typeof createDiscordNativeCommand>,
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
name: string,
): CommandOption | undefined {
return command.options?.find((entry) => entry.name === name);
}
function requireOption(
command: ReturnType<typeof createDiscordNativeCommand>,
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
name: string,
): CommandOption {
const option = findOption(command, name);
@@ -60,6 +64,13 @@ function readChoices(option: CommandOption | undefined): unknown[] | undefined {
}
describe("createDiscordNativeCommand option wiring", () => {
beforeEach(async () => {
vi.resetModules();
({ listNativeCommandSpecs } = await import("../../../../src/auto-reply/commands-registry.js"));
({ createDiscordNativeCommand } = await import("./native-command.js"));
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
});
it("uses autocomplete for /acp action so inline action values are accepted", async () => {
const command = createNativeCommand("acp");
const action = requireOption(command, "action");
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest";
import { __testing } from "./provider.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
let __testing: typeof import("./provider.js").__testing;
describe("resolveThreadBindingsEnabled", () => {
beforeEach(async () => {
vi.resetModules();
({ __testing } = await import("./provider.js"));
});
it("defaults to enabled when unset", () => {
expect(
__testing.resolveThreadBindingsEnabled({
@@ -34,11 +34,14 @@ vi.mock("../send.js", async (importOriginal) => {
};
});
const { maybeSendBindingMessage, resolveChannelIdForBinding } =
await import("./thread-bindings.discord-api.js");
let maybeSendBindingMessage: typeof import("./thread-bindings.discord-api.js").maybeSendBindingMessage;
let resolveChannelIdForBinding: typeof import("./thread-bindings.discord-api.js").resolveChannelIdForBinding;
describe("resolveChannelIdForBinding", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ maybeSendBindingMessage, resolveChannelIdForBinding } =
await import("./thread-bindings.discord-api.js"));
hoisted.restGet.mockClear();
hoisted.createDiscordRestClient.mockClear();
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
@@ -124,7 +127,10 @@ describe("resolveChannelIdForBinding", () => {
});
describe("maybeSendBindingMessage", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ maybeSendBindingMessage, resolveChannelIdForBinding } =
await import("./thread-bindings.discord-api.js"));
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({});
});
@@ -15,7 +15,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
let closeDiscordThreadSessions: typeof import("./thread-session-close.js").closeDiscordThreadSessions;
function setupStore(store: Record<string, { updatedAt: number }>) {
hoisted.updateSessionStore.mockImplementation(
@@ -30,7 +30,9 @@ const MATCHED_KEY = `agent:main:discord:channel:${THREAD_ID}`;
const UNMATCHED_KEY = `agent:main:discord:channel:${OTHER_ID}`;
describe("closeDiscordThreadSessions", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ closeDiscordThreadSessions } = await import("./thread-session-close.js"));
hoisted.updateSessionStore.mockClear();
hoisted.resolveStorePath.mockClear();
hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json");
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
const hoisted = vi.hoisted(() => {
const sendMessageDiscordMock = vi.fn();
@@ -37,7 +36,14 @@ vi.mock("./monitor/thread-bindings.js", async (importOriginal) => {
};
});
const { discordOutbound } = await import("./outbound-adapter.js");
let normalizeDiscordOutboundTarget: typeof import("./normalize.js").normalizeDiscordOutboundTarget;
let discordOutbound: typeof import("./outbound-adapter.js").discordOutbound;
beforeEach(async () => {
vi.resetModules();
({ normalizeDiscordOutboundTarget } = await import("./normalize.js"));
({ discordOutbound } = await import("./outbound-adapter.js"));
});
const DEFAULT_DISCORD_SEND_RESULT = {
channel: "discord",
+16 -9
View File
@@ -1,11 +1,5 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordComponentEntries } from "./components-registry.js";
import {
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} from "./send.components.js";
import { makeDiscordRest } from "./send.test-harness.js";
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
@@ -24,10 +18,23 @@ vi.mock("./components-registry.js", () => ({
registerDiscordComponentEntries: vi.fn(),
}));
describe("sendDiscordComponentMessage", () => {
const registerMock = vi.mocked(registerDiscordComponentEntries);
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
let editDiscordComponentMessage: typeof import("./send.components.js").editDiscordComponentMessage;
let registerBuiltDiscordComponentMessage: typeof import("./send.components.js").registerBuiltDiscordComponentMessage;
let sendDiscordComponentMessage: typeof import("./send.components.js").sendDiscordComponentMessage;
beforeEach(() => {
describe("sendDiscordComponentMessage", () => {
let registerMock: ReturnType<typeof vi.mocked<typeof registerDiscordComponentEntries>>;
beforeEach(async () => {
vi.resetModules();
({ registerDiscordComponentEntries } = await import("./components-registry.js"));
({
editDiscordComponentMessage,
registerBuiltDiscordComponentMessage,
sendDiscordComponentMessage,
} = await import("./send.components.js"));
registerMock = vi.mocked(registerDiscordComponentEntries);
vi.clearAllMocks();
});
@@ -2,21 +2,6 @@ import { RateLimitError } from "@buape/carbon";
import { ChannelType, Routes } from "discord-api-types/v10";
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
listGuildEmojisDiscord,
listThreadsDiscord,
reactMessageDiscord,
removeRoleDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("openclaw/plugin-sdk/web-media", async () => {
@@ -24,11 +9,41 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
return discordWebMediaMockFactory();
});
describe("sendMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
let addRoleDiscord: typeof import("./send.js").addRoleDiscord;
let banMemberDiscord: typeof import("./send.js").banMemberDiscord;
let createThreadDiscord: typeof import("./send.js").createThreadDiscord;
let listGuildEmojisDiscord: typeof import("./send.js").listGuildEmojisDiscord;
let listThreadsDiscord: typeof import("./send.js").listThreadsDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
let removeRoleDiscord: typeof import("./send.js").removeRoleDiscord;
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
let sendPollDiscord: typeof import("./send.js").sendPollDiscord;
let sendStickerDiscord: typeof import("./send.js").sendStickerDiscord;
let timeoutMemberDiscord: typeof import("./send.js").timeoutMemberDiscord;
let uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord;
let uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord;
beforeEach(async () => {
vi.resetModules();
({
addRoleDiscord,
banMemberDiscord,
createThreadDiscord,
listGuildEmojisDiscord,
listThreadsDiscord,
reactMessageDiscord,
removeRoleDiscord,
sendMessageDiscord,
sendPollDiscord,
sendStickerDiscord,
timeoutMemberDiscord,
uploadEmojiDiscord,
uploadStickerDiscord,
} = await import("./send.js"));
vi.clearAllMocks();
});
describe("sendMessageDiscord", () => {
it("creates a thread", async () => {
const { rest, getMock, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "t1" });
@@ -1,11 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import {
fetchMemberGuildPermissionsDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
} from "./send.permissions.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockRest = vi.hoisted(() => ({
get: vi.fn(),
@@ -15,6 +10,10 @@ vi.mock("./client.js", () => ({
resolveDiscordRest: () => mockRest as unknown as RequestClient,
}));
let fetchMemberGuildPermissionsDiscord: typeof import("./send.permissions.js").fetchMemberGuildPermissionsDiscord;
let hasAllGuildPermissionsDiscord: typeof import("./send.permissions.js").hasAllGuildPermissionsDiscord;
let hasAnyGuildPermissionDiscord: typeof import("./send.permissions.js").hasAnyGuildPermissionDiscord;
type RouteMockParams = {
guildId?: string;
userId?: string;
@@ -44,6 +43,16 @@ function mockGuildMemberRoutes(params: RouteMockParams): void {
}
describe("discord guild permission authorization", () => {
beforeEach(async () => {
vi.resetModules();
({
fetchMemberGuildPermissionsDiscord,
hasAllGuildPermissionsDiscord,
hasAnyGuildPermissionDiscord,
} = await import("./send.permissions.js"));
mockRest.get.mockReset();
});
describe("fetchMemberGuildPermissionsDiscord", () => {
it("returns null when user is not a guild member", async () => {
mockRest.get.mockRejectedValueOnce(new Error("404 Member not found"));
@@ -1,24 +1,5 @@
import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetDiscordDirectoryCacheForTest,
rememberDiscordDirectoryUser,
} from "./directory-cache.js";
import {
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
unpinMessageDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("openclaw/plugin-sdk/web-media", async () => {
@@ -26,6 +7,45 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
return discordWebMediaMockFactory();
});
let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord;
let editMessageDiscord: typeof import("./send.js").editMessageDiscord;
let fetchChannelPermissionsDiscord: typeof import("./send.js").fetchChannelPermissionsDiscord;
let fetchReactionsDiscord: typeof import("./send.js").fetchReactionsDiscord;
let pinMessageDiscord: typeof import("./send.js").pinMessageDiscord;
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
let readMessagesDiscord: typeof import("./send.js").readMessagesDiscord;
let removeOwnReactionsDiscord: typeof import("./send.js").removeOwnReactionsDiscord;
let removeReactionDiscord: typeof import("./send.js").removeReactionDiscord;
let searchMessagesDiscord: typeof import("./send.js").searchMessagesDiscord;
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
let unpinMessageDiscord: typeof import("./send.js").unpinMessageDiscord;
let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia;
let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js").__resetDiscordDirectoryCacheForTest;
let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser;
beforeEach(async () => {
vi.resetModules();
({
deleteMessageDiscord,
editMessageDiscord,
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,
pinMessageDiscord,
reactMessageDiscord,
readMessagesDiscord,
removeOwnReactionsDiscord,
removeReactionDiscord,
searchMessagesDiscord,
sendMessageDiscord,
unpinMessageDiscord,
} = await import("./send.js"));
({ loadWebMedia } = await import("openclaw/plugin-sdk/web-media"));
({ __resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser } =
await import("./directory-cache.js"));
vi.clearAllMocks();
__resetDiscordDirectoryCacheForTest();
});
describe("sendMessageDiscord", () => {
function expectReplyReference(
body: { message_reference?: unknown } | undefined,
@@ -65,11 +85,6 @@ describe("sendMessageDiscord", () => {
return { rest, postMock };
}
beforeEach(() => {
vi.clearAllMocks();
__resetDiscordDirectoryCacheForTest();
});
it("sends basic channel messages", async () => {
const { rest, postMock, getMock } = makeDiscordRest();
// Channel type lookup returns a normal text channel (not a forum).
+7 -2
View File
@@ -1,6 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveDiscordRestMock = vi.hoisted(() => vi.fn());
@@ -8,7 +8,12 @@ vi.mock("./client.js", () => ({
resolveDiscordRest: resolveDiscordRestMock,
}));
import { sendTypingDiscord } from "./send.typing.js";
let sendTypingDiscord: typeof import("./send.typing.js").sendTypingDiscord;
beforeEach(async () => {
vi.resetModules();
({ sendTypingDiscord } = await import("./send.typing.js"));
});
describe("sendTypingDiscord", () => {
it("sends a typing event to the resolved Discord channel route", async () => {
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendWebhookMessageDiscord } from "./send.js";
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } })));
@@ -20,8 +19,12 @@ vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => {
};
});
let sendWebhookMessageDiscord: typeof import("./send.js").sendWebhookMessageDiscord;
describe("sendWebhookMessageDiscord activity", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ sendWebhookMessageDiscord } = await import("./send.js"));
recordChannelActivityMock.mockClear();
loadConfigMock.mockClear();
vi.stubGlobal(
+47 -102
View File
@@ -1,146 +1,91 @@
import type { ChildProcess, ExecFileOptions } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
type ExecCallback = (
error: NodeJS.ErrnoException | null,
stdout: string | Buffer,
stderr: string | Buffer,
) => void;
type ExecCall = {
command: string;
args: string[];
options?: ExecFileOptions;
};
type MockExecResult = {
stdout?: string;
stderr?: string;
error?: NodeJS.ErrnoException;
};
const execCalls: ExecCall[] = [];
const mockExecResults: MockExecResult[] = [];
vi.mock("node:child_process", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:child_process")>();
const execFileImpl = (
file: string,
args?: readonly string[] | null,
optionsOrCallback?: ExecFileOptions | ExecCallback | null,
callbackMaybe?: ExecCallback,
) => {
const normalizedArgs = Array.isArray(args) ? [...args] : [];
const callback =
typeof optionsOrCallback === "function" ? optionsOrCallback : (callbackMaybe ?? undefined);
const options =
typeof optionsOrCallback === "function" ? undefined : (optionsOrCallback ?? undefined);
execCalls.push({
command: file,
args: normalizedArgs,
options,
});
const next = mockExecResults.shift() ?? { stdout: "", stderr: "" };
queueMicrotask(() => {
callback?.(next.error ?? null, next.stdout ?? "", next.stderr ?? "");
});
return {} as ChildProcess;
};
const execFileWithCustomPromisify = execFileImpl as unknown as typeof actual.execFile & {
[promisify.custom]?: (
file: string,
args?: readonly string[] | null,
options?: ExecFileOptions | null,
) => Promise<{ stdout: string | Buffer; stderr: string | Buffer }>;
};
execFileWithCustomPromisify[promisify.custom] = (
file: string,
args?: readonly string[] | null,
options?: ExecFileOptions | null,
) =>
new Promise<{ stdout: string | Buffer; stderr: string | Buffer }>((resolve, reject) => {
execFileImpl(file, args, options, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
const runFfprobeMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<string>>());
const runFfmpegMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>());
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
execFile: execFileWithCustomPromisify,
resolvePreferredOpenClawTmpDir: () => "/tmp",
};
});
vi.mock("../../../src/infra/tmp-openclaw-dir.js", () => ({
resolvePreferredOpenClawTmpDir: () => "/tmp",
}));
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
runFfprobe: runFfprobeMock,
runFfmpeg: runFfmpegMock,
parseFfprobeCodecAndSampleRate: (stdout: string) => {
const [codec, sampleRate] = stdout.trim().split(",");
return {
codec,
sampleRateHz: Number(sampleRate),
};
},
MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS: 1200,
unlinkIfExists: vi.fn(async () => {}),
};
});
const { ensureOggOpus } = await import("./voice-message.js");
let ensureOggOpus: typeof import("./voice-message.js").ensureOggOpus;
describe("ensureOggOpus", () => {
beforeEach(() => {
execCalls.length = 0;
mockExecResults.length = 0;
beforeEach(async () => {
vi.resetModules();
runFfprobeMock.mockReset();
runFfmpegMock.mockReset();
({ ensureOggOpus } = await import("./voice-message.js"));
});
afterEach(() => {
execCalls.length = 0;
mockExecResults.length = 0;
runFfprobeMock.mockReset();
runFfmpegMock.mockReset();
});
it("rejects URL/protocol input paths", async () => {
await expect(ensureOggOpus("https://example.com/audio.ogg")).rejects.toThrow(
/local file path/i,
);
expect(execCalls).toHaveLength(0);
expect(runFfprobeMock).not.toHaveBeenCalled();
expect(runFfmpegMock).not.toHaveBeenCalled();
});
it("keeps .ogg only when codec is opus and sample rate is 48kHz", async () => {
mockExecResults.push({ stdout: "opus,48000\n" });
runFfprobeMock.mockResolvedValueOnce("opus,48000\n");
const result = await ensureOggOpus("/tmp/input.ogg");
expect(result).toEqual({ path: "/tmp/input.ogg", cleanup: false });
expect(execCalls).toHaveLength(1);
expect(execCalls[0].command).toBe("ffprobe");
expect(execCalls[0].args).toContain("stream=codec_name,sample_rate");
expect(execCalls[0].options?.timeout).toBe(10_000);
expect(runFfprobeMock).toHaveBeenCalledWith(
expect.arrayContaining(["-show_entries", "stream=codec_name,sample_rate", "/tmp/input.ogg"]),
);
expect(runFfmpegMock).not.toHaveBeenCalled();
});
it("re-encodes .ogg opus when sample rate is not 48kHz", async () => {
mockExecResults.push({ stdout: "opus,24000\n" });
mockExecResults.push({ stdout: "" });
runFfprobeMock.mockResolvedValueOnce("opus,24000\n");
runFfmpegMock.mockResolvedValueOnce();
const result = await ensureOggOpus("/tmp/input.ogg");
const ffmpegCall = execCalls.find((call) => call.command === "ffmpeg");
expect(result.cleanup).toBe(true);
expect(result.path).toMatch(/^\/tmp\/voice-.*\.ogg$/);
expect(ffmpegCall).toBeDefined();
expect(ffmpegCall?.args).toContain("-t");
expect(ffmpegCall?.args).toContain("1200");
expect(ffmpegCall?.args).toContain("-ar");
expect(ffmpegCall?.args).toContain("48000");
expect(ffmpegCall?.options?.timeout).toBe(45_000);
expect(runFfmpegMock).toHaveBeenCalledWith(
expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg", result.path]),
);
});
it("re-encodes non-ogg input with bounded ffmpeg execution", async () => {
mockExecResults.push({ stdout: "" });
runFfmpegMock.mockResolvedValueOnce();
const result = await ensureOggOpus("/tmp/input.mp3");
const ffprobeCalls = execCalls.filter((call) => call.command === "ffprobe");
const ffmpegCalls = execCalls.filter((call) => call.command === "ffmpeg");
expect(result.cleanup).toBe(true);
expect(ffprobeCalls).toHaveLength(0);
expect(ffmpegCalls).toHaveLength(1);
expect(ffmpegCalls[0].options?.timeout).toBe(45_000);
expect(ffmpegCalls[0].args).toEqual(expect.arrayContaining(["-vn", "-sn", "-dn"]));
expect(runFfprobeMock).not.toHaveBeenCalled();
expect(runFfmpegMock).toHaveBeenCalledWith(
expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3", result.path]),
);
});
});
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runDuckDuckGoSearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runDuckDuckGoSearch } = vi.hoisted(() => ({
runDuckDuckGoSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./ddg-client.js", () => ({
runDuckDuckGoSearch,
}));
describe("duckduckgo web search provider", () => {
beforeEach(() => {
vi.resetModules();
runDuckDuckGoSearch.mockReset();
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes keyless metadata and enables the plugin in config", async () => {
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
+34 -1
View File
@@ -18,6 +18,7 @@ const getChatMembersMock = vi.hoisted(() => vi.fn());
const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn());
const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn());
const feishuOutboundSendMediaMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
@@ -47,7 +48,7 @@ vi.mock("./channel.runtime.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
feishuOutbound: {
sendText: vi.fn(),
sendMedia: vi.fn(),
sendMedia: feishuOutboundSendMediaMock,
},
},
}));
@@ -204,6 +205,38 @@ describe("feishuPlugin actions", () => {
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
});
it("sends media through the outbound adapter", async () => {
feishuOutboundSendMediaMock.mockResolvedValueOnce({
channel: "feishu",
messageId: "om_media",
details: { messageId: "om_media", chatId: "oc_group_1" },
});
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: "test",
media: "/tmp/image.png",
},
cfg,
accountId: undefined,
toolContext: {},
mediaLocalRoots: ["/tmp"],
} as never);
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
text: "test",
mediaUrl: "/tmp/image.png",
accountId: undefined,
mediaLocalRoots: ["/tmp"],
replyToId: undefined,
});
expect(result?.details).toMatchObject({ messageId: "om_media" });
});
it("reads messages", async () => {
getMessageFeishuMock.mockResolvedValueOnce({
messageId: "om_1",
+49 -19
View File
@@ -51,6 +51,14 @@ import { feishuSetupWizard } from "./setup-surface.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { FeishuConfig, FeishuProbeResult, ResolvedFeishuAccount } from "./types.js";
function readFeishuMediaParam(params: Record<string, unknown>): string | undefined {
const media = params.media;
if (typeof media !== "string") {
return undefined;
}
return media.trim() ? media : undefined;
}
const meta: ChannelMeta = {
id: "feishu",
label: "Feishu",
@@ -502,27 +510,49 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
? (ctx.params.card as Record<string, unknown>)
: undefined;
const text = readFirstString(ctx.params, ["text", "message"]);
if (!card && !text) {
throw new Error(`Feishu ${ctx.action} requires text/message or card.`);
const mediaUrl = readFeishuMediaParam(ctx.params);
if (card && mediaUrl) {
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
}
if (!card && !text && !mediaUrl) {
throw new Error(`Feishu ${ctx.action} requires text/message, media, or card.`);
}
const runtime = await loadFeishuChannelRuntime();
const result = card
? await runtime.sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
})
: await runtime.sendMessageFeishu({
cfg: ctx.cfg,
to,
text: text!,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
});
const maybeSendMedia = runtime.feishuOutbound.sendMedia;
if (mediaUrl && !maybeSendMedia) {
throw new Error("Feishu media sending is not available.");
}
const sendMedia = maybeSendMedia;
let result;
if (card) {
result = await runtime.sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
});
} else if (mediaUrl) {
result = await sendMedia!({
cfg: ctx.cfg,
to,
text: text ?? "",
mediaUrl,
accountId: ctx.accountId ?? undefined,
mediaLocalRoots: ctx.mediaLocalRoots,
replyToId: replyToMessageId,
});
} else {
result = await runtime.sendMessageFeishu({
cfg: ctx.cfg,
to,
text: text!,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
replyInThread: ctx.action === "thread-reply",
});
}
return jsonActionResult({
ok: true,
channel: "feishu",
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlScrape = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runFirecrawlScrape } = vi.hoisted(() => ({
runFirecrawlScrape: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./firecrawl-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
}));
describe("firecrawl scrape tool", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlScrape.mockReset();
runFirecrawlScrape.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("maps scrape params and defaults extract mode to markdown", async () => {
const { createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js");
const tool = createFirecrawlScrapeTool({
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runFirecrawlSearch } = vi.hoisted(() => ({
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./firecrawl-client.js", () => ({
runFirecrawlSearch,
}));
describe("firecrawl web search provider", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlSearch.mockReset();
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes selection metadata and enables the plugin in config", async () => {
const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js");
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
const { runFirecrawlSearch } = vi.hoisted(() => ({
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => ({
ok: true,
params,
})),
}));
vi.mock("./firecrawl-client.js", () => ({
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
}));
describe("firecrawl search tool", () => {
beforeEach(() => {
vi.resetModules();
runFirecrawlSearch.mockReset();
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => ({
ok: true,
params,
}));
});
it("normalizes optional search parameters before invoking Firecrawl", async () => {
const { createFirecrawlSearchTool } = await import("./firecrawl-search-tool.js");
const tool = createFirecrawlSearchTool({
+27 -16
View File
@@ -14,32 +14,43 @@ vi.mock("../send.js", () => ({
sendMessageIMessageMock(to, message, opts),
}));
vi.mock("../../../../src/auto-reply/chunk.js", () => ({
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => ({}),
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
};
});
vi.mock("../../../../src/config/config.js", () => ({
loadConfig: () => ({}),
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
resolveChunkMode: () => resolveChunkModeMock(),
};
});
vi.mock("../../../../src/config/markdown-tables.js", () => ({
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
}));
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
return {
...actual,
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
};
});
vi.mock("../../../../src/markdown/tables.js", () => ({
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
}));
import { deliverReplies } from "./deliver.js";
let deliverReplies: typeof import("./deliver.js").deliverReplies;
describe("deliverReplies", () => {
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
chunkTextWithModeMock.mockImplementation((text: string) => [text]);
({ deliverReplies } = await import("./deliver.js"));
});
it("propagates payload replyToId through all text chunks", async () => {
+30 -12
View File
@@ -1,6 +1,6 @@
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LineAccountConfig } from "./types.js";
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
@@ -74,6 +74,16 @@ type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLi
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
function buildDefaultLineMessageContext() {
return {
ctxPayload: { From: "line:group:group-1" },
replyToken: "reply-token",
route: { agentId: "default" },
isGroup: true,
accountId: "default",
};
}
function createReplayMessageEvent(params: {
messageId: string;
groupId: string;
@@ -199,17 +209,24 @@ async function startInflightReplayDuplicate(params: {
}
describe("handleLineWebhookEvents", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
buildLineMessageContextMock.mockReset();
buildLineMessageContextMock.mockImplementation(async () => ({
ctxPayload: { From: "line:group:group-1" },
replyToken: "reply-token",
route: { agentId: "default" },
isGroup: true,
accountId: "default",
}));
buildLinePostbackContextMock.mockReset();
buildLinePostbackContextMock.mockImplementation(async () => null as unknown);
readAllowFromStoreMock.mockReset();
readAllowFromStoreMock.mockImplementation(async () => [] as string[]);
upsertPairingRequestMock.mockReset();
upsertPairingRequestMock.mockImplementation(async () => ({ code: "CODE", created: true }));
({ handleLineWebhookEvents, createLineWebhookReplayCache } = await import("./bot-handlers.js"));
});
beforeEach(() => {
buildLineMessageContextMock.mockClear();
buildLinePostbackContextMock.mockClear();
readAllowFromStoreMock.mockClear();
upsertPairingRequestMock.mockClear();
});
it("blocks group messages when groupPolicy is disabled", async () => {
const processMessage = vi.fn();
const event = {
@@ -573,10 +590,11 @@ describe("handleLineWebhookEvents", () => {
isRedelivery: true,
});
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
const firstFailure = expect(firstRun).rejects.toThrow("transient inflight failure");
const secondFailure = expect(secondRun).rejects.toThrow("transient inflight failure");
rejectFirst?.(new Error("transient inflight failure"));
await expect(firstRun).rejects.toThrow("transient inflight failure");
await expect(secondRun).rejects.toThrow("transient inflight failure");
await Promise.all([firstFailure, secondFailure]);
expect(processMessage).toHaveBeenCalledTimes(1);
});
+6 -3
View File
@@ -29,7 +29,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: () => {},
}));
import { downloadLineMedia } from "./download.js";
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
for (const part of parts) {
@@ -38,8 +38,11 @@ async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
}
describe("downloadLineMedia", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(async () => {
vi.restoreAllMocks();
vi.resetModules();
getMessageContentMock.mockReset();
({ downloadLineMedia } = await import("./download.js"));
});
it("does not derive temp file path from external messageId", async () => {
+12 -8
View File
@@ -11,6 +11,8 @@ const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = v
unregisterHttpMock: vi.fn(),
}));
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
vi.mock("./bot.js", () => ({
createLineBot: createLineBotMock,
}));
@@ -74,14 +76,19 @@ vi.mock("./template-messages.js", () => ({
}));
describe("monitorLineProvider lifecycle", () => {
beforeEach(() => {
createLineBotMock.mockClear();
unregisterHttpMock.mockClear();
registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
beforeEach(async () => {
vi.resetModules();
createLineBotMock.mockReset();
createLineBotMock.mockReturnValue({
account: { accountId: "default" },
handleWebhook: vi.fn(),
});
unregisterHttpMock.mockReset();
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
({ monitorLineProvider } = await import("./monitor.js"));
});
it("waits for abort before resolving", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
let resolved = false;
@@ -108,7 +115,6 @@ describe("monitorLineProvider lifecycle", () => {
});
it("stops immediately when signal is already aborted", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const abort = new AbortController();
abort.abort();
@@ -124,8 +130,6 @@ describe("monitorLineProvider lifecycle", () => {
});
it("returns immediately without abort signal and stop is idempotent", async () => {
const { monitorLineProvider } = await import("./monitor.js");
const monitor = await monitorLineProvider({
channelAccessToken: "token",
channelSecret: "secret", // pragma: allowlist secret
+8 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
const getBotInfoMock = vi.fn();
const MessagingApiClientMock = vi.fn(function () {
@@ -19,7 +19,13 @@ afterEach(() => {
});
describe("probeLineBot", () => {
beforeAll(async () => {
beforeEach(async () => {
vi.resetModules();
getBotInfoMock.mockReset();
MessagingApiClientMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return { getBotInfo: getBotInfoMock };
});
({ probeLineBot } = await import("./probe.js"));
});
+13 -7
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
pushMessageMock,
@@ -74,28 +74,34 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
let sendModule: typeof import("./send.js");
describe("LINE send helpers", () => {
beforeAll(async () => {
sendModule = await import("./send.js");
});
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
pushMessageMock.mockReset();
replyMessageMock.mockReset();
showLoadingAnimationMock.mockReset();
getProfileMock.mockReset();
MessagingApiClientMock.mockClear();
MessagingApiClientMock.mockReset();
loadConfigMock.mockReset();
resolveLineAccountMock.mockReset();
resolveLineChannelAccessTokenMock.mockReset();
recordChannelActivityMock.mockReset();
logVerboseMock.mockReset();
MessagingApiClientMock.mockImplementation(function () {
return {
pushMessage: pushMessageMock,
replyMessage: replyMessageMock,
showLoadingAnimation: showLoadingAnimationMock,
getProfile: getProfileMock,
};
});
loadConfigMock.mockReturnValue({});
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
pushMessageMock.mockResolvedValue({});
replyMessageMock.mockResolvedValue({});
showLoadingAnimationMock.mockResolvedValue({});
sendModule = await import("./send.js");
});
afterEach(() => {
+20
View File
@@ -35,6 +35,26 @@ describe("matrix plugin registration", () => {
});
}, 240_000);
it("loads the matrix src runtime api through Jiti without duplicate export errors", () => {
const runtimeApiPath = path.join(
process.cwd(),
"extensions",
"matrix",
"src",
"runtime-api.ts",
);
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
),
tryNative: false,
});
expect(jiti(runtimeApiPath)).toMatchObject({
resolveMatrixAccountStringValues: expect.any(Function),
});
}, 240_000);
it("registers the channel without bootstrapping crypto runtime", () => {
const runtime = {} as never;
matrixPlugin.register({
+2 -3
View File
@@ -14,6 +14,5 @@ export {
maybeCreateMatrixMigrationSnapshot,
resolveConfiguredAcpBindingRecord,
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
// Keep auth-precedence available internally without re-exporting helper-api
// twice through both plugin-sdk/matrix and ../runtime-api.js.
export * from "./auth-precedence.js";
// resolveMatrixAccountStringValues already comes from plugin-sdk/matrix.
// Re-exporting auth-precedence here makes Jiti try to define the same export twice.
+1
View File
@@ -4,6 +4,7 @@
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.48",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
+4
View File
@@ -1,4 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/infra-runtime";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
export type MiniMaxRegion = "cn" | "global";
@@ -184,6 +185,9 @@ export async function loginMiniMaxPortalOAuth(params: {
progress: { update: (message: string) => void; stop: (message?: string) => void };
region?: MiniMaxRegion;
}): Promise<MiniMaxOAuthToken> {
// Ensure env-based proxy dispatcher is active before any outbound fetch calls.
// Without this, HTTP_PROXY/HTTPS_PROXY env vars are silently ignored (#51619).
ensureGlobalUndiciEnvProxyDispatcher();
const region = params.region ?? "global";
const { verifier, challenge, state } = generatePkce();
const oauth = await requestOAuthCode({ challenge, state, region });
+2 -2
View File
@@ -39,13 +39,13 @@ describe("mistral model definitions", () => {
reasoning: true,
input: ["text"],
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 40000,
}),
expect.objectContaining({
id: "pixtral-large-latest",
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 32768,
}),
]),
);
+5 -5
View File
@@ -4,7 +4,7 @@ export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1";
export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest";
export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`;
export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144;
export const MISTRAL_DEFAULT_MAX_TOKENS = 262144;
export const MISTRAL_DEFAULT_MAX_TOKENS = 16384;
export const MISTRAL_DEFAULT_COST = {
input: 0.5,
output: 1.5,
@@ -29,7 +29,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text"],
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
maxTokens: 32768,
},
{
id: "magistral-small",
@@ -38,7 +38,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text"],
cost: { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 40000,
},
{
id: "mistral-large-latest",
@@ -56,7 +56,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text", "image"],
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
maxTokens: 8192,
},
{
id: "mistral-small-latest",
@@ -74,7 +74,7 @@ const MISTRAL_MODEL_CATALOG = [
input: ["text", "image"],
cost: { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 128000,
maxTokens: 32768,
},
] as const satisfies readonly ModelDefinitionConfig[];
+12 -2
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MSTeamsConfig } from "../runtime-api.js";
const hostMockState = vi.hoisted(() => ({
@@ -20,6 +20,17 @@ vi.mock("@microsoft/agents-hosting", () => ({
import { probeMSTeams } from "./probe.js";
describe("msteams probe", () => {
beforeEach(() => {
hostMockState.tokenError = null;
vi.stubEnv("MSTEAMS_APP_ID", "");
vi.stubEnv("MSTEAMS_APP_PASSWORD", "");
vi.stubEnv("MSTEAMS_TENANT_ID", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns an error when credentials are missing", async () => {
const cfg = { enabled: true } as unknown as MSTeamsConfig;
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
@@ -28,7 +39,6 @@ describe("msteams probe", () => {
});
it("validates credentials by acquiring a token", async () => {
hostMockState.tokenError = null;
const cfg = {
enabled: true,
appId: "app",
+6 -7
View File
@@ -3,19 +3,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchWithTimeoutMock = vi.fn();
const resolveFetchMock = vi.fn();
vi.mock("../../../src/infra/fetch.js", () => ({
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
}));
vi.mock("../../../src/infra/secure-random.js", () => ({
generateSecureUuid: () => "test-id",
}));
vi.mock("../../../src/utils/fetch-timeout.js", () => ({
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
}));
import { signalRpcRequest } from "./client.js";
let signalRpcRequest: typeof import("./client.js").signalRpcRequest;
function rpcResponse(body: unknown, status = 200): Response {
if (typeof body === "string") {
@@ -25,9 +22,11 @@ function rpcResponse(body: unknown, status = 200): Response {
}
describe("signalRpcRequest", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
resolveFetchMock.mockReturnValue(vi.fn());
({ signalRpcRequest } = await import("./client.js"));
});
it("returns parsed RPC result", async () => {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
config,
flush,
@@ -8,9 +8,7 @@ import {
} from "./monitor.tool-result.test-harness.js";
installSignalToolResultTestHooks();
// Import after the harness registers `vi.mock(...)` for Signal internals.
const { monitorSignalProvider } = await import("./monitor.js");
let monitorSignalProvider: typeof import("./monitor.js").monitorSignalProvider;
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
getSignalToolResultTestMocks();
@@ -21,6 +19,11 @@ async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider(opts);
}
describe("monitorSignalProvider tool results", () => {
beforeEach(async () => {
vi.resetModules();
({ monitorSignalProvider } = await import("./monitor.js"));
});
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
const baseChannels = (config.channels ?? {}) as Record<string, unknown>;
const baseSignal = (baseChannels.signal ?? {}) as Record<string, unknown>;
@@ -1,11 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
import { createSignalEventHandler } from "./event-handler.js";
import {
createBaseSignalEventHandlerDeps,
createSignalReceiveEvent,
} from "./event-handler.test-harness.js";
let expectInboundContextContract: typeof import("../../../../src/channels/plugins/contracts/suites.js").expectChannelInboundContextContract;
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
() => {
@@ -49,8 +46,17 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
upsertChannelPairingRequest: vi.fn(),
}));
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
describe("signal createSignalEventHandler inbound context", () => {
beforeEach(() => {
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
({ expectChannelInboundContextContract: expectInboundContextContract } =
await import("../../../../src/channels/plugins/contracts/suites.js"));
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
await import("./event-handler.test-harness.js"));
({ createSignalEventHandler } = await import("./event-handler.js"));
capture.ctx = undefined;
sendTypingMock.mockReset().mockResolvedValue(true);
sendReadReceiptMock.mockReset().mockResolvedValue(true);
@@ -1,11 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
import { buildDispatchInboundCaptureMock } from "../../../../src/channels/plugins/contracts/inbound-testkit.js";
import type { OpenClawConfig } from "../../../../src/config/types.js";
import {
createBaseSignalEventHandlerDeps,
createSignalReceiveEvent,
} from "./event-handler.test-harness.js";
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
Body?: string;
@@ -18,15 +14,17 @@ function getCapturedCtx() {
return capturedCtx as SignalMsgContext;
}
vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/auto-reply/dispatch.js")>();
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return buildDispatchInboundCaptureMock(actual, (ctx) => {
capturedCtx = ctx as SignalMsgContext;
});
});
import { createSignalEventHandler } from "./event-handler.js";
import { renderSignalMentions } from "./mentions.js";
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
let renderSignalMentions: typeof import("./mentions.js").renderSignalMentions;
type GroupEventOpts = {
message?: string;
@@ -102,8 +100,16 @@ async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: str
}
describe("signal mention gating", () => {
it("drops group messages without mention when requireMention is configured", async () => {
beforeEach(async () => {
vi.resetModules();
capturedCtx = undefined;
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
await import("./event-handler.test-harness.js"));
({ createSignalEventHandler } = await import("./event-handler.js"));
({ renderSignalMentions } = await import("./mentions.js"));
});
it("drops group messages without mention when requireMention is configured", async () => {
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "hello everyone" }));
@@ -111,7 +117,6 @@ describe("signal mention gating", () => {
});
it("allows group messages with mention when requireMention is configured", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "hey @bot what's up" }));
@@ -120,7 +125,6 @@ describe("signal mention gating", () => {
});
it("sets WasMentioned=false for group messages without mention when requireMention is off", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: false });
await handler(makeGroupEvent({ message: "hello everyone" }));
@@ -129,7 +133,6 @@ describe("signal mention gating", () => {
});
it("records pending history for skipped group messages", async () => {
capturedCtx = undefined;
const { handler, groupHistories } = createMentionGatedHistoryHandler();
await handler(makeGroupEvent({ message: "hello from alice" }));
expect(capturedCtx).toBeUndefined();
@@ -147,7 +150,6 @@ describe("signal mention gating", () => {
});
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
capturedCtx = undefined;
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
@@ -172,7 +174,6 @@ describe("signal mention gating", () => {
});
it("summarizes multiple skipped attachments with stable file count wording", async () => {
capturedCtx = undefined;
const groupHistories = new Map();
const handler = createSignalEventHandler(
createBaseSignalEventHandlerDeps({
@@ -204,7 +205,6 @@ describe("signal mention gating", () => {
});
it("bypasses mention gating for authorized control commands", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: true });
await handler(makeGroupEvent({ message: "/help" }));
@@ -212,7 +212,6 @@ describe("signal mention gating", () => {
});
it("hydrates mention placeholders before trimming so offsets stay aligned", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({ requireMention: false });
const placeholder = "\uFFFC";
@@ -237,7 +236,6 @@ describe("signal mention gating", () => {
});
it("counts mention metadata replacements toward requireMention gating", async () => {
capturedCtx = undefined;
const handler = createMentionHandler({
requireMention: true,
mentionPattern: "@123e4567",
@@ -7,7 +7,7 @@ vi.mock("./monitor/media.js", () => ({
resolveSlackMedia: (...args: Parameters<typeof resolveSlackMedia>) => resolveSlackMedia(...args),
}));
const { downloadSlackFile } = await import("./actions.js");
let downloadSlackFile: typeof import("./actions.js").downloadSlackFile;
function createClient() {
return {
@@ -68,8 +68,10 @@ function mockSuccessfulMediaDownload(client: ReturnType<typeof createClient>) {
}
describe("downloadSlackFile", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
resolveSlackMedia.mockReset();
({ downloadSlackFile } = await import("./actions.js"));
});
it("returns null when files.info has no private download URL", async () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import {
type ChannelMessageActionAdapter,
type ChannelMessageToolDiscovery,
@@ -40,7 +41,7 @@ export function createSlackActions(
schema: actions.includes("send")
? {
properties: {
blocks: createSlackMessageToolBlocksSchema(),
blocks: Type.Optional(createSlackMessageToolBlocksSchema()),
},
}
: null,
+20
View File
@@ -1,3 +1,4 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { slackOutbound } from "./outbound-adapter.js";
@@ -96,6 +97,25 @@ describe("slackPlugin actions", () => {
});
});
it("keeps blocks optional in the message tool schema", () => {
const discovery = slackPlugin.actions?.describeMessageTool({
cfg: {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
},
} as OpenClawConfig,
});
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected slack message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
});
it("forwards read threadId to Slack action handler", async () => {
handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false });
const handleAction = requireSlackHandleAction();
+12 -5
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@slack/web-api", () => {
const WebClient = vi.fn(function WebClientMock(
@@ -12,11 +12,18 @@ vi.mock("@slack/web-api", () => {
return { WebClient };
});
const slackWebApi = await import("@slack/web-api");
const { createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
await import("./client.js");
let createSlackWebClient: typeof import("./client.js").createSlackWebClient;
let resolveSlackWebClientOptions: typeof import("./client.js").resolveSlackWebClientOptions;
let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETRY_OPTIONS;
let WebClient: ReturnType<typeof vi.fn>;
const WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.resetModules();
const slackWebApi = await import("@slack/web-api");
({ createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
await import("./client.js"));
WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
});
describe("slack web client config", () => {
it("applies the default retry config when none is provided", () => {
@@ -10,7 +10,7 @@ import {
stopSlackMonitor,
} from "./monitor.test-helpers.js";
const { monitorSlackProvider } = await import("./monitor.js");
let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
const slackTestState = getSlackTestState();
@@ -69,6 +69,12 @@ async function runMissingThreadScenario(params: {
beforeEach(() => {
resetInboundDedupe();
});
beforeEach(async () => {
vi.resetModules();
({ monitorSlackProvider } = await import("./monitor.js"));
resetInboundDedupe();
resetSlackTestState({
messages: { responsePrefix: "PFX" },
channels: {
@@ -12,15 +12,23 @@ import {
stopSlackMonitor,
} from "./monitor.test-helpers.js";
const { resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js");
const { HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js");
const { CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js");
const { monitorSlackProvider } = await import("./monitor.js");
let resetInboundDedupe: typeof import("../../../src/auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
let HISTORY_CONTEXT_MARKER: typeof import("../../../src/auto-reply/reply/history.js").HISTORY_CONTEXT_MARKER;
let CURRENT_MESSAGE_MARKER: typeof import("../../../src/auto-reply/reply/mentions.js").CURRENT_MESSAGE_MARKER;
let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
const slackTestState = getSlackTestState();
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
beforeEach(() => {
vi.resetModules();
});
beforeEach(async () => {
({ resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js"));
({ HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js"));
({ CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js"));
({ monitorSlackProvider } = await import("./monitor.js"));
resetInboundDedupe();
resetSlackTestState(defaultSlackTestConfig());
});
+22 -14
View File
@@ -1,13 +1,18 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SlackMonitorContext } from "./context.js";
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const readStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn());
let clearSlackAllowFromCacheForTest: typeof import("./auth.js").clearSlackAllowFromCacheForTest;
let resolveSlackEffectiveAllowFrom: typeof import("./auth.js").resolveSlackEffectiveAllowFrom;
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readChannelAllowFromStoreMock(...args),
}));
import { clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } from "./auth.js";
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) =>
readStoreAllowFromForDmPolicyMock(...args),
};
});
function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
return {
@@ -20,8 +25,11 @@ function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
describe("resolveSlackEffectiveAllowFrom", () => {
const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
beforeEach(() => {
readChannelAllowFromStoreMock.mockReset();
beforeEach(async () => {
vi.resetModules();
({ clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } =
await import("./auth.js"));
readStoreAllowFromForDmPolicyMock.mockReset();
clearSlackAllowFromCacheForTest();
if (prevTtl === undefined) {
delete process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
@@ -31,7 +39,7 @@ describe("resolveSlackEffectiveAllowFrom", () => {
});
it("falls back to channel config allowFrom when pairing store throws", async () => {
readChannelAllowFromStoreMock.mockRejectedValueOnce(new Error("boom"));
readStoreAllowFromForDmPolicyMock.mockRejectedValueOnce(new Error("boom"));
const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"]));
@@ -40,7 +48,7 @@ describe("resolveSlackEffectiveAllowFrom", () => {
});
it("treats malformed non-array pairing-store responses as empty", async () => {
readChannelAllowFromStoreMock.mockReturnValueOnce(undefined);
readStoreAllowFromForDmPolicyMock.mockReturnValueOnce(undefined);
const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"]));
@@ -49,7 +57,7 @@ describe("resolveSlackEffectiveAllowFrom", () => {
});
it("memoizes pairing-store allowFrom reads within TTL", async () => {
readChannelAllowFromStoreMock.mockResolvedValue(["u2"]);
readStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]);
const ctx = makeSlackCtx(["u1"]);
const first = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
@@ -57,17 +65,17 @@ describe("resolveSlackEffectiveAllowFrom", () => {
expect(first.allowFrom).toEqual(["u1", "u2"]);
expect(second.allowFrom).toEqual(["u1", "u2"]);
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(1);
expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(1);
});
it("refreshes pairing-store allowFrom when cache TTL is zero", async () => {
process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS = "0";
readChannelAllowFromStoreMock.mockResolvedValue(["u2"]);
readStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]);
const ctx = makeSlackCtx(["u1"]);
await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(2);
expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(2);
});
});
@@ -1,12 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { registerSlackChannelEvents } from "./channels.js";
import { createSlackSystemEventTestHarness } from "./system-event-test-harness.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const enqueueSystemEventMock = vi.fn();
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
let registerSlackChannelEvents: typeof import("./channels.js").registerSlackChannelEvents;
let createSlackSystemEventTestHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
vi.mock("../../../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
type SlackChannelHandler = (args: {
event: Record<string, unknown>;
@@ -28,6 +32,13 @@ function createChannelContext(params?: {
}
describe("registerSlackChannelEvents", () => {
beforeEach(async () => {
vi.resetModules();
enqueueSystemEventMock.mockClear();
({ registerSlackChannelEvents } = await import("./channels.js"));
({ createSlackSystemEventTestHarness } = await import("./system-event-test-harness.js"));
});
it("does not track mismatched events", async () => {
const trackEvent = vi.fn();
const { getCreatedHandler } = createChannelContext({
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerSlackInteractionEvents } from "./interactions.js";
const enqueueSystemEventMock = vi.fn();
const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
@@ -10,20 +9,28 @@ const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
const resolvePluginConversationBindingApprovalMock = vi.fn();
const buildPluginBindingResolvedTextMock = vi.fn(() => "Binding updated.");
vi.mock("../../../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) =>
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) =>
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
};
});
vi.mock("../../../../../src/plugins/interactive.js", () => ({
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
...actual,
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
};
});
vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../../src/plugins/conversation-binding.js")
>("../../../../../src/plugins/conversation-binding.js");
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
@@ -35,6 +42,13 @@ vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
};
});
let registerSlackInteractionEvents: typeof import("./interactions.js").registerSlackInteractionEvents;
vi.mock("../../../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) =>
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
}));
type RegisteredHandler = (args: {
ack: () => Promise<void>;
body: {
@@ -183,7 +197,9 @@ function createContext(overrides?: {
}
describe("registerSlackInteractionEvents", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ registerSlackInteractionEvents } = await import("./interactions.js"));
enqueueSystemEventMock.mockClear();
dispatchPluginInteractiveHandlerMock.mockClear();
resolvePluginConversationBindingApprovalMock.mockClear();
@@ -1,22 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { registerSlackMemberEvents } from "./members.js";
import {
createSlackSystemEventTestHarness as initSlackHarness,
type SlackSystemEventTestOverrides as MemberOverrides,
} from "./system-event-test-harness.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const memberMocks = vi.hoisted(() => ({
enqueue: vi.fn(),
readAllow: vi.fn(),
}));
let registerSlackMemberEvents: typeof import("./members.js").registerSlackMemberEvents;
let initSlackHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
type MemberOverrides = import("./system-event-test-harness.js").SlackSystemEventTestOverrides;
vi.mock("../../../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: memberMocks.enqueue,
}));
vi.mock("../../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: memberMocks.readAllow,
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return { ...actual, enqueueSystemEvent: memberMocks.enqueue };
});
type MemberHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
@@ -56,7 +50,6 @@ function getMemberHandlers(params: {
async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
memberMocks.enqueue.mockClear();
memberMocks.readAllow.mockReset().mockResolvedValue([]);
const handlers = getMemberHandlers({
overrides: args.overrides,
trackEvent: args.trackEvent,
@@ -72,6 +65,14 @@ async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
}
describe("registerSlackMemberEvents", () => {
beforeEach(async () => {
vi.resetModules();
memberMocks.enqueue.mockClear();
({ registerSlackMemberEvents } = await import("./members.js"));
({ createSlackSystemEventTestHarness: initSlackHarness } =
await import("./system-event-test-harness.js"));
});
const cases: Array<{ name: string; args: MemberCaseArgs; calls: number }> = [
{
name: "enqueues DM member events when dmPolicy is open",
@@ -1,5 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { registerSlackMessageEvents } from "./messages.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createSlackSystemEventTestHarness,
type SlackSystemEventTestOverrides,
@@ -8,13 +7,23 @@ import {
const messageQueueMock = vi.fn();
const messageAllowMock = vi.fn();
vi.mock("../../../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args),
}));
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args),
};
});
vi.mock("../../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args),
};
});
let registerSlackMessageEvents: typeof import("./messages.js").registerSlackMessageEvents;
type MessageHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
type RegisteredEventName = "message" | "app_mention";
@@ -43,6 +52,11 @@ function resetMessageMocks(): void {
messageAllowMock.mockReset().mockResolvedValue([]);
}
beforeEach(async () => {
vi.resetModules();
({ registerSlackMessageEvents } = await import("./messages.js"));
});
function makeChangedEvent(overrides?: { channel?: string; user?: string }) {
const user = overrides?.user ?? "U1";
return {
@@ -1,19 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { registerSlackPinEvents } from "./pins.js";
import {
createSlackSystemEventTestHarness as buildPinHarness,
type SlackSystemEventTestOverrides as PinOverrides,
} from "./system-event-test-harness.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const pinEnqueueMock = vi.hoisted(() => vi.fn());
const pinAllowMock = vi.hoisted(() => vi.fn());
let registerSlackPinEvents: typeof import("./pins.js").registerSlackPinEvents;
let buildPinHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
type PinOverrides = import("./system-event-test-harness.js").SlackSystemEventTestOverrides;
vi.mock("../../../../../src/infra/system-events.js", () => {
return { enqueueSystemEvent: pinEnqueueMock };
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return { ...actual, enqueueSystemEvent: pinEnqueueMock };
});
vi.mock("../../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: pinAllowMock,
}));
type PinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
@@ -57,7 +52,6 @@ function installPinHandlers(args: {
async function runPinCase(input: PinCase = {}): Promise<void> {
pinEnqueueMock.mockClear();
pinAllowMock.mockReset().mockResolvedValue([]);
const { added, removed } = installPinHandlers({
overrides: input.overrides,
trackEvent: input.trackEvent,
@@ -75,6 +69,14 @@ async function runPinCase(input: PinCase = {}): Promise<void> {
}
describe("registerSlackPinEvents", () => {
beforeEach(async () => {
vi.resetModules();
pinEnqueueMock.mockClear();
({ registerSlackPinEvents } = await import("./pins.js"));
({ createSlackSystemEventTestHarness: buildPinHarness } =
await import("./system-event-test-harness.js"));
});
const cases: Array<{ name: string; args: PinCase; expectedCalls: number }> = [
{
name: "enqueues DM pin system events when dmPolicy is open",
@@ -1,25 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { registerSlackReactionEvents } from "./reactions.js";
import {
createSlackSystemEventTestHarness,
type SlackSystemEventTestOverrides,
} from "./system-event-test-harness.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const reactionQueueMock = vi.fn();
const reactionAllowMock = vi.fn();
const reactionQueueMock = vi.hoisted(() => vi.fn());
let registerSlackReactionEvents: typeof import("./reactions.js").registerSlackReactionEvents;
let createSlackSystemEventTestHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
type SlackSystemEventTestOverrides =
import("./system-event-test-harness.js").SlackSystemEventTestOverrides;
vi.mock("../../../../../src/infra/system-events.js", () => {
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args),
};
});
vi.mock("../../../../../src/pairing/pairing-store.js", () => {
return {
readChannelAllowFromStore: (...args: unknown[]) => reactionAllowMock(...args),
};
});
type ReactionHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
type ReactionRunInput = {
@@ -63,7 +57,6 @@ function createReactionHandlers(params: {
async function executeReactionCase(input: ReactionRunInput = {}) {
reactionQueueMock.mockClear();
reactionAllowMock.mockReset().mockResolvedValue([]);
const handlers = createReactionHandlers({
overrides: input.overrides,
trackEvent: input.trackEvent,
@@ -78,6 +71,13 @@ async function executeReactionCase(input: ReactionRunInput = {}) {
}
describe("registerSlackReactionEvents", () => {
beforeEach(async () => {
vi.resetModules();
reactionQueueMock.mockClear();
({ registerSlackReactionEvents } = await import("./reactions.js"));
({ createSlackSystemEventTestHarness } = await import("./system-event-test-harness.js"));
});
const cases: Array<{ name: string; input: ReactionRunInput; expectedCalls: number }> = [
{
name: "enqueues DM reaction system events when dmPolicy is open",
@@ -156,7 +156,6 @@ describe("registerSlackReactionEvents", () => {
it("passes sender context when resolving reaction session keys", async () => {
reactionQueueMock.mockClear();
reactionAllowMock.mockReset().mockResolvedValue([]);
const harness = createSlackSystemEventTestHarness();
const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:main");
harness.ctx.resolveSlackSystemEventSessionKey = resolveSessionKey;
@@ -50,7 +50,7 @@ vi.mock("./message-handler/dispatch.js", () => ({
dispatchPreparedSlackMessageMock(prepared),
}));
import { createSlackMessageHandler } from "./message-handler.js";
let createSlackMessageHandler: typeof import("./message-handler.js").createSlackMessageHandler;
function createMarkMessageSeen() {
const seen = new Set<string>();
@@ -117,7 +117,9 @@ async function createInFlightMessageScenario(ts: string) {
}
describe("createSlackMessageHandler app_mention race handling", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ createSlackMessageHandler } = await import("./message-handler.js"));
prepareSlackMessageMock.mockReset();
dispatchPreparedSlackMessageMock.mockReset();
});
@@ -1,19 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSlackMessageHandler } from "./message-handler.js";
const enqueueMock = vi.fn(async (_entry: unknown) => {});
const flushKeyMock = vi.fn(async (_key: string) => {});
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
...message,
}));
let createSlackMessageHandler: typeof import("./message-handler.js").createSlackMessageHandler;
vi.mock("../../../../src/auto-reply/inbound-debounce.js", () => ({
resolveInboundDebounceMs: () => 10,
createInboundDebouncer: () => ({
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
}),
}));
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
return {
...actual,
createChannelInboundDebouncer: () => ({
debounceMs: 10,
debouncer: {
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
},
}),
shouldDebounceTextInbound: ({ hasMedia }: { hasMedia?: boolean }) => !hasMedia,
};
});
vi.mock("./thread-resolution.js", () => ({
createSlackThreadTsResolver: () => ({
@@ -63,10 +70,12 @@ async function handleDirectMessage(
}
describe("createSlackMessageHandler", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
enqueueMock.mockClear();
flushKeyMock.mockClear();
resolveThreadTsMock.mockClear();
({ createSlackMessageHandler } = await import("./message-handler.js"));
});
it("does not track invalid non-message events from the message stream", async () => {
+4 -2
View File
@@ -5,7 +5,7 @@ vi.mock("../send.js", () => ({
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
}));
import { deliverReplies } from "./replies.js";
let deliverReplies: typeof import("./replies.js").deliverReplies;
function baseParams(overrides?: Record<string, unknown>) {
return {
@@ -20,7 +20,9 @@ function baseParams(overrides?: Record<string, unknown>) {
}
describe("deliverReplies identity passthrough", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ deliverReplies } = await import("./replies.js"));
sendMock.mockReset();
});
it("passes identity to sendMessageSlack for text replies", async () => {
+3 -1
View File
@@ -194,7 +194,9 @@ async function loadRegisterSlackMonitorSlashCommands(): Promise<RegisterFn> {
const { dispatchMock } = getSlackSlashMocks();
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
registerSlackMonitorSlashCommandsPromise = undefined;
resetSlackSlashMocks();
});
+5 -3
View File
@@ -8,14 +8,15 @@ vi.mock("./client.js", () => ({
createSlackWebClient: createSlackWebClientMock,
}));
vi.mock("../../../src/utils/with-timeout.js", () => ({
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
withTimeout: withTimeoutMock,
}));
const { probeSlack } = await import("./probe.js");
let probeSlack: typeof import("./probe.js").probeSlack;
describe("probeSlack", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
authTestMock.mockReset();
createSlackWebClientMock.mockReset();
withTimeoutMock.mockReset();
@@ -26,6 +27,7 @@ describe("probeSlack", () => {
},
});
withTimeoutMock.mockImplementation(async (promise: Promise<unknown>) => await promise);
({ probeSlack } = await import("./probe.js"));
});
it("maps Slack auth metadata on success", async () => {
+4 -2
View File
@@ -31,7 +31,7 @@ vi.mock("openclaw/plugin-sdk/web-media", () => ({
})),
}));
const { sendMessageSlack } = await import("./send.js");
let sendMessageSlack: typeof import("./send.js").sendMessageSlack;
type UploadTestClient = WebClient & {
conversations: { open: ReturnType<typeof vi.fn> };
@@ -64,7 +64,9 @@ function createUploadTestClient(): UploadTestClient {
describe("sendMessageSlack file upload with user IDs", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
({ sendMessageSlack } = await import("./send.js"));
globalThis.fetch = vi.fn(
async () => new Response("ok", { status: 200 }),
) as unknown as typeof fetch;
@@ -1,12 +1,15 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./tavily-client.js", () => ({
const { runTavilyExtract } = vi.hoisted(() => ({
runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })),
}));
import { runTavilyExtract } from "./tavily-client.js";
import { createTavilyExtractTool } from "./tavily-extract-tool.js";
vi.mock("./tavily-client.js", () => ({
runTavilyExtract,
}));
let createTavilyExtractTool: typeof import("./tavily-extract-tool.js").createTavilyExtractTool;
function fakeApi(): OpenClawPluginApi {
return {
@@ -15,8 +18,11 @@ function fakeApi(): OpenClawPluginApi {
}
describe("tavily_extract", () => {
beforeEach(() => {
vi.clearAllMocks();
beforeEach(async () => {
vi.resetModules();
runTavilyExtract.mockReset();
runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params }));
({ createTavilyExtractTool } = await import("./tavily-extract-tool.js"));
});
it("rejects chunks_per_source without query", async () => {
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const runTavilySearch = vi.fn(async (params: Record<string, unknown>) => params);
const { runTavilySearch } = vi.hoisted(() => ({
runTavilySearch: vi.fn(async (params: Record<string, unknown>) => params),
}));
vi.mock("./tavily-client.js", () => ({
runTavilySearch,
}));
describe("tavily web search provider", () => {
beforeEach(() => {
vi.resetModules();
runTavilySearch.mockReset();
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => params);
});
it("exposes the expected metadata and selection wiring", async () => {
const { createTavilyWebSearchProvider } = await import("./tavily-search-provider.js");

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