Compare commits

...

61 Commits

Author SHA1 Message Date
Peter Steinberger
c8972376cb docs(changelog): remove codex credits 2026-04-26 20:20:03 +01:00
Peter Steinberger
377041cd75 chore(release): bump 2026.4.25 beta 9 2026-04-26 20:06:06 +01:00
Peter Steinberger
d32a7916bd docs(changelog): note beta 9 backports 2026-04-26 20:05:50 +01:00
Vincent Koc
2c625f9368 fix: repair skills and memory watcher refresh paths
(cherry picked from commit e53c068d78)
2026-04-26 20:05:32 +01:00
Peter Steinberger
5ea41fe40c test(gateway): classify stream fallback as empty live response
(cherry picked from commit 4e181d30fa)
2026-04-26 20:05:23 +01:00
Peter Steinberger
cec1d46b30 test(gateway): harden acp bind docker smoke
(cherry picked from commit e60cc50dff)
2026-04-26 20:05:23 +01:00
Peter Steinberger
a8ba87ee90 fix(agents): keep responses web search reasoning compatible
(cherry picked from commit f2dab9b334)
2026-04-26 20:05:10 +01:00
Peter Steinberger
3f821a8888 fix(agents): honor bundle mcp tool allowlist
(cherry picked from commit fc6cfbd418)
2026-04-26 20:05:10 +01:00
Vincent Koc
1a3c480155 fix: shortcut live session model redirects during fallback
(cherry picked from commit 480a3f66c9)
2026-04-26 20:05:10 +01:00
Vincent Koc
683437fe61 fix(discord): escalate repeated health-monitor restarts
(cherry picked from commit b4cdd55f62)
2026-04-26 20:04:53 +01:00
Peter Steinberger
095e1a90f5 docs(release): allow retagging unpublished betas 2026-04-26 19:39:52 +01:00
Peter Steinberger
227a07558b docs(changelog): place auto-reply backport in 2026.4.25 2026-04-26 19:33:19 +01:00
Vincent Koc
773e302179 fix(auto-reply): poison inbound dedupe after partial turn failure
* fix(auto-reply): poison inbound dedupe after replay-unsafe failures

* fix(clownfish): address review for ghcrawl-165980-agentic-merge (1)
2026-04-26 19:10:34 +01:00
Peter Steinberger
ec71b01f71 chore(release): bump 2026.4.25 beta 8 2026-04-26 18:56:52 +01:00
Peter Steinberger
ca9fb36d53 docs(changelog): place WhatsApp backport in 2026.4.25 2026-04-26 18:50:48 +01:00
Vincent Koc
1f194f1d55 fix(whatsapp): stop reconnecting quiet sockets
Fixes #70678.\n\nKeeps quiet but healthy WhatsApp linked-device sessions connected by tracking WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Also cleans up transport activity listeners on failed connection-open paths.\n\nCarries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.\n\nValidation:\n- pnpm test:serial extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts extensions/whatsapp/src/connection-controller.test.ts\n- pnpm check:changed\n- codex review --base origin/main
2026-04-26 18:37:01 +01:00
Peter Steinberger
a188d486dd chore(release): bump 2026.4.25 beta 7 2026-04-26 18:17:26 +01:00
Peter Steinberger
a4266be808 test(release): stabilize release validation waits 2026-04-26 18:11:12 +01:00
Peter Steinberger
90c40e9f90 chore(release): bump 2026.4.25 beta 6 2026-04-26 17:20:37 +01:00
Peter Steinberger
b77514b6d9 fix: avoid PowerShell error variable collision 2026-04-26 17:19:53 +01:00
Peter Steinberger
a813219b6b chore(release): bump 2026.4.25 beta 5 2026-04-26 16:48:34 +01:00
Peter Steinberger
4ac1406644 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 16:24:13 +01:00
Peter Steinberger
4d0e1470df fix(release): stabilize beta validation lanes 2026-04-26 16:21:56 +01:00
Peter Steinberger
6ecae22943 chore(release): bump 2026.4.25 beta 4 2026-04-26 14:24:00 +01:00
Peter Steinberger
2c5ac5c0e2 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 14:17:08 +01:00
Peter Steinberger
8c309aa3de chore(release): bump 2026.4.25 beta 3 2026-04-26 13:54:23 +01:00
Peter Steinberger
3c89b16fb0 test(release): wait longer for dashboard smoke 2026-04-26 13:53:50 +01:00
Peter Steinberger
ef447c43c7 test(qa): allow slower gateway rpc startup retries 2026-04-26 13:51:24 +01:00
Peter Steinberger
ddb66a71af Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 13:49:27 +01:00
Peter Steinberger
9b1583112a test(extensions): restore transformed dynamic imports 2026-04-26 13:15:46 +01:00
Peter Steinberger
865fde8f72 chore(release): bump 2026.4.25 beta 2 2026-04-26 13:00:00 +01:00
Peter Steinberger
ccc8d71461 fix(cli): keep channel add plugin install noninteractive 2026-04-26 12:58:33 +01:00
Peter Steinberger
a947464403 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:46:42 +01:00
Peter Steinberger
63803d78f4 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:15:22 +01:00
Peter Steinberger
dcad0256b2 docs(plugin-sdk): refresh api baseline after main sync 2026-04-26 12:08:12 +01:00
Peter Steinberger
12b1a63b84 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:07:37 +01:00
Peter Steinberger
6ea3f30b9b Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 12:03:01 +01:00
Peter Steinberger
660dcf2c94 docs(plugin-sdk): refresh api baseline after main sync 2026-04-26 11:52:40 +01:00
Peter Steinberger
26ab654da2 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:52:07 +01:00
Peter Steinberger
5bc728d480 docs(release): refine beta validation guidance 2026-04-26 11:51:06 +01:00
Peter Steinberger
3779853ef9 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:45:31 +01:00
Peter Steinberger
b4ff947206 fix(ui): remove ineffective dynamic imports 2026-04-26 11:45:22 +01:00
Peter Steinberger
1e464867e7 Merge remote-tracking branch 'origin/main' into release/2026.4.25 2026-04-26 11:41:22 +01:00
Peter Steinberger
ea9da71f03 test: type setup provider mocks 2026-04-26 11:41:08 +01:00
Peter Steinberger
1dbc246e29 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	CHANGELOG.md
2026-04-26 11:39:46 +01:00
Peter Steinberger
41c7256420 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	.agents/skills/openclaw-release-maintainer/SKILL.md
#	CHANGELOG.md
#	package.json
#	src/config/schema.base.generated.ts
2026-04-26 10:00:50 +01:00
Peter Steinberger
b7733c48c0 docs(release): codify beta train backport scan 2026-04-26 09:58:34 +01:00
Peter Steinberger
50565b05aa docs(changelog): add 2026.4.25 release highlights 2026-04-26 09:40:56 +01:00
Vincent Koc
2e10d87919 docs(changelog): flatten 27 multi-line bullets into single lines per AGENTS.md rule
(cherry picked from commit eb6b35671a)
2026-04-26 09:40:42 +01:00
Peter Steinberger
0ca3fae91a fix: hide raw agent failures in group chats
(cherry picked from commit 1969452c3f)
2026-04-26 09:40:40 +01:00
Peter Steinberger
308ba59151 test: update npm telegram workflow expectations
(cherry picked from commit 4ad8b613c9)
2026-04-26 09:40:38 +01:00
Vincent Koc
6ca5907692 fix(runtime): harden dependency install surfaces (#71997)
* fix(runtime): harden dependency surfaces

* fix(runtime): harden dependency install surfaces

* fix(runtime): address dependency surface review

* fix(runtime): address dependency surface review

* fix(channels): avoid read-only plugin loader cycle

* fix(channels): allow optional read-only loader workspace

* test(commands): refresh current main checks

* test(commands): keep provider metadata mock unique

* test(commands): keep doctor security read-only mock unique

(cherry picked from commit abd5ec98ab)
2026-04-26 09:40:19 +01:00
Peter Steinberger
b9758bf44a docs(plugin-sdk): refresh beta api baseline after main sync 2026-04-26 09:23:31 +01:00
Peter Steinberger
b923421129 Merge remote-tracking branch 'origin/main' into release/2026.4.25
# Conflicts:
#	src/plugin-sdk/command-auth.ts
#	src/plugins/command-registration.ts
#	src/plugins/command-registry-state.ts
#	src/plugins/command-specs.ts
#	src/plugins/commands.ts
2026-04-26 09:18:34 +01:00
Peter Steinberger
c6276d6b19 docs(plugin-sdk): refresh api baseline 2026-04-26 08:58:39 +01:00
Peter Steinberger
399b41bbdb docs(config): refresh channel config baseline 2026-04-26 08:57:20 +01:00
Peter Steinberger
1ce1713139 chore(config): refresh bundled channel metadata 2026-04-26 08:56:20 +01:00
Peter Steinberger
1768995c37 chore(release): sync beta config schema 2026-04-26 08:54:43 +01:00
Peter Steinberger
ced0e96cf2 fix: break plugin command spec import cycle 2026-04-26 08:46:02 +01:00
Peter Steinberger
dd13141903 fix: satisfy traceparent header lint 2026-04-26 08:43:26 +01:00
Peter Steinberger
072a5ae4b0 chore(release): prepare 2026.4.25 beta 1 2026-04-26 08:41:57 +01:00
42 changed files with 1467 additions and 168 deletions

View File

@@ -41,9 +41,16 @@ Use this skill for release and publish-time workflow. Keep ordinary development
recommended replacement can shift as plugin ownership, externalization, and
config footprint move, so do not blindly copy stale replacement annotations
into release notes.
- Do not delete or rewrite beta tags after they leave the machine. If a
published or pushed beta needs a fix, commit the fix on the release branch and
- Do not delete or rewrite any beta tag after the matching npm package has been
published, or after a GitHub release/prerelease was created from that tag. If
an npm-published beta needs a fix, commit the fix on the release branch and
increment to the next `-beta.N`.
- Beta-only Git tags that were pushed for preflight but never published to npm
may be moved or replaced when the operator explicitly approves it. Before
retagging, verify `npm view openclaw@YYYY.M.D-beta.N version` is unpublished
and no GitHub release/prerelease exists for the tag; after retagging, push the
updated tag intentionally and rerun npm preflight because older preflight
artifacts are tied to the previous tag SHA.
- For a beta release train, run the fast local preflight first, publish the
beta to npm `beta`, then run the expensive published-package roster focused
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on

View File

@@ -6,22 +6,25 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways. Thanks @codex.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex.
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Gateway/Bonjour: keep @homebridge/ciao cancellation handlers registered across advertiser restarts so late probing cancellations cannot crash Linux and other mDNS-churned gateways.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries. Thanks @codex.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds. Thanks @codex.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries. Thanks @codex.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
- WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @codex.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
## 2026.4.26
### Fixes
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation. Thanks @codex.
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
@@ -118,13 +121,36 @@ Docs: https://docs.openclaw.ai
### Fixes
- Auto-reply: poison inbound message dedupe after replay-unsafe provider/runtime failures so retries stay safe before visible progress but cannot duplicate messages after block output, tool side effects, or session progress. Fixes #69303; keeps #58549 and #64606 as duplicate validation. Thanks @martingarramon, @NikolaFC, and @zeroth-blip.
- Agents/OpenAI: keep Responses web search compatible with minimal thinking by raising `web_search` requests to the lowest supported reasoning effort instead of sending a rejected minimal payload.
- Agents/tools: honor the `bundle-mcp` allowlist token when deciding whether bundled MCP tools are available, so restricted tool policies can still enable bundled MCP without exposing unrelated tools.
- Agents/model fallback: jump directly to a known later live-session model redirect instead of walking unrelated fallback candidates, while preserving the already-landed live-session/fallback loop guard. Fixes #57471; related loop family already closed via #58496. Thanks @yuxiaoyang2007-prog.
- Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020.
- Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez.
- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation.
- Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`.
- Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet.
- Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc.
- Plugins/compat: add missing dated compatibility records for legacy extension-api, memory registration, provider hook/type aliases, runtime aliases, channel SDK helpers, and approval/test utility shims. Thanks @vincentkoc.
- Plugins/CLI: refresh the persisted registry after managed plugin files are removed so ClawHub uninstall cannot leave stale `plugins list` entries.
- Plugins/CLI: make plugin install and uninstall config writes conflict-aware, clear stale denylist entries on explicit reinstall/removal, and delete managed plugin files only after config/index commit succeeds.
- Plugins: fail `plugins update` when tracked plugin or hook updates error, keep bundled runtime-dependency repair behind restrictive allowlists, and reject package installs with unloadable extension entries.
- Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00.
- Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans.
- WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis.
- Onboarding/setup: keep first-run config reads, plugin compatibility notices, and post-model sanity checks on cold metadata paths unless the user chooses to browse all models, avoiding full plugin/runtime catalog work between prompts. Thanks @shakkernerd.
- Onboarding/auth: run manifest-owned provider auth choices through scoped setup providers so selecting OpenAI Codex browser/device auth no longer loads every provider runtime before OAuth starts. Thanks @shakkernerd.
- Onboarding/auth: keep the post-auth default-model policy lookup on manifest/setup metadata so the next prompt appears without loading broad provider runtime. Thanks @shakkernerd.
- Onboarding/models: keep skip-auth and provider-scoped model picker prompts off the full global model catalog path, and cache provider catalog hook resolution so setup no longer stalls after auth on large plugin registries. Thanks @shakkernerd.
- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create.
- Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse.
- Agents/subagents: deliver completed yielded-subagent results back to no-thread requester routes via direct fallback when the dormant parent announce turn produces no visible reply, and add QA-lab coverage for the regression. Thanks @vincentkoc.
- Gateway/Tailscale: let Tailscale-authenticated Control UI operator sessions with browser device identity skip the device-pairing round trip while still rejecting device-less and node-role connections. Refs #71986. Thanks @jokedul.
- Doctor: honor `OPENCLAW_SERVICE_REPAIR_POLICY=external` by reporting gateway service health while skipping service install/start/restart/bootstrap, supervisor rewrites, and legacy service cleanup for externally managed environments. Thanks @shakkernerd.
- CLI/update: run package post-update doctor with `--fix` so package updates repair config migrations before restart. Thanks @shakkernerd.
- CLI/update: retry failed npm global updates with `--omit=optional` and ignore the superseded first failure when the fallback succeeds. Thanks @shakkernerd.
- Plugins/uninstall: migrate and reset `plugins.slots.contextEngine` alongside memory slots when plugin ids change or selected plugins are removed. Thanks @shakkernerd.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled. Thanks @codex.
- Agents/Discord: keep raw `Agent failed before reply` runner failures out of Discord group/channel chats and show detailed runner errors in direct chats only when `/verbose` is enabled.
- UI/Windows: quote resolved pnpm `.cmd` launcher paths before spawning UI install/build/test commands so Node installs under `C:\Program Files` no longer fail as `C:\Program`. Fixes #45275. Thanks @Kobevictor, @stoppieboy, and @iubns.
- Codex/agent: translate `--thinking minimal` to `low` for modern Codex models (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.2) at request build time so the first turn is accepted instead of paying a wasted call + retry-with-low fallback. Older Codex models still receive `minimal` directly. Fixes #71946. Thanks @hclsys.
- Plugins/uninstall: remove tracked plugin files from their recorded managed extensions root even when the current state directory points somewhere else, so `openclaw plugins uninstall --force` does not leave the plugin discoverable. Thanks @shakkernerd.
@@ -226,7 +252,7 @@ Docs: https://docs.openclaw.ai
- macOS/remote SSH: keep discovered gateway hosts in `gateway.remote.sshTarget` while pinning SSH transport URLs to the local loopback tunnel, so browser automation does not regress into blocked non-loopback `ws://` endpoints. Fixes #67336.
- Gateway/proxy: bootstrap env proxy dispatching from direct Gateway startup so provider and plugin network requests honor `HTTPS_PROXY`/`HTTP_PROXY` before the first embedded agent attempt runs. (#71833) Thanks @mjamiv.
- Plugins/runtime deps: verify clean npm installs actually place requested bundled runtime packages in the managed install root, reporting exact missing specs instead of a false successful repair. (#71883) Thanks @Solvely-Colin.
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path. Thanks @codex.
- Plugins/discovery: ignore stale `plugins.load.paths` aliases that point back at packaged bundled plugin directories and have doctor remove them, keeping bundled plugins on the runtime-deps staging path.
- Models/LM Studio: preserve `@iq*` quant suffixes in model refs and provider matching so `/model lmstudio/...@iq3_xxs` keeps the exact LM Studio variant. Fixes #71474. (#71486) Thanks @Bartok9, @XinwuC, and @Sanjays2402.
- Matrix/cron: preserve the live Matrix delivery target when creating implicit announce reminder jobs so mixed-case room IDs are not reconstructed from lowercased session keys. Fixes #71798.
- Feishu: accept Schema 2.0 card action callbacks that report `context.open_chat_id` instead of legacy `context.chat_id`, so button callbacks no longer drop as malformed. Fixes #71670. Thanks @eddy1068.
@@ -4584,7 +4610,7 @@ Docs: https://docs.openclaw.ai
- Slack/Allowlist channels: match channel IDs case-insensitively during channel allowlist resolution so lowercase config keys (for example `c0abc12345`) correctly match Slack runtime IDs (`C0ABC12345`) under `groupPolicy: "allowlist"`, preventing silent channel-event drops. (#26878) Thanks @lbo728.
- Discord/Typing indicator: prevent stuck typing indicators by sealing channel typing keepalive callbacks after idle/cleanup and ensuring Discord dispatch always marks typing idle even if preview-stream cleanup fails. (#26295) Thanks @ngutman.
- Channels/Typing indicator: guard typing keepalive start callbacks after idle/cleanup close so post-close ticks cannot re-trigger stale typing indicators. (#26325) Thanks @win4r.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881) Thanks @codexGW.
- Followups/Typing indicator: ensure followup turns mark dispatch idle on every exit path (including `NO_REPLY`, empty payloads, and agent errors) so typing keepalive cleanup always runs and channel typing indicators do not get stuck after queued/silent followups. (#26881)
- Voice-call/TTS tools: hide the `tts` tool when the message provider is `voice`, preventing voice-call runs from selecting self-playback TTS and falling into silent no-output loops. (#27025).
- Agents/Tools: normalize non-standard plugin tool results that omit `content` so embedded runs no longer crash with `Cannot read properties of undefined (reading 'filter')` after tool completion (including `tesseramemo_query`). (#27007).
- Agents/Tool-call dispatch: trim whitespace-padded tool names in both transcript repair and live streamed embedded-runner responses so exact-match tool lookup no longer fails with `Tool .. not found` for model outputs like `" read "`. (#27094) Thanks @openperf and @Sid-Qin.

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026042600
versionName = "2026.4.26"
versionCode = 2026042500
versionName = "2026.4.25"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,9 +1,5 @@
# OpenClaw iOS Changelog
## 2026.4.26 - 2026-04-26
Maintenance update for the current OpenClaw development release.
## 2026.4.25 - 2026-04-25
Maintenance update for the current OpenClaw development release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.4.26
OPENCLAW_MARKETING_VERSION = 2026.4.26
OPENCLAW_IOS_VERSION = 2026.4.25
OPENCLAW_MARKETING_VERSION = 2026.4.25
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.26"
"version": "2026.4.25"
}

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.26</string>
<string>2026.4.25</string>
<key>CFBundleVersion</key>
<string>2026042600</string>
<string>2026042500</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -1,4 +1,4 @@
7fa6e35bb9f9d3096d6281f141488be0dcfe15de40dc4f5c0305eb1ff2bc60b6 config-baseline.json
91a6cec6c5bdf4b0bf955a058955278990a1af31f32f8fcf2ac26d7548fb99e5 config-baseline.json
5f5fb87fd46f9cbb84d8af17e00ae3c4b74062e8ad517bc2260ba83da2e9014f config-baseline.core.json
7cd9c908f066c143eab2a201efbc9640f483ab28bba92ddeca1d18cc2b528bc3 config-baseline.channel.json
080c0a4f2d4175d6d7ab1e38f76b21de32669055c518d75c96e784865d89bf25 config-baseline.channel.json
f9e0174988718959fe1923a54496ec5b9262721fe1e7306f32ccb1316d9d9c3f config-baseline.plugin.json

View File

@@ -146,6 +146,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
## Runtime model
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window.
- Outbound sends require an active WhatsApp listener for the target account.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
@@ -510,6 +511,10 @@ Behavior notes:
<Accordion title="Linked but disconnected / reconnect loop">
Symptom: linked account with repeated disconnects or reconnect attempts.
Quiet accounts can stay connected past the normal message timeout; the watchdog
restarts when WhatsApp Web transport activity stops, the socket closes, or
application-level activity stays silent beyond the longer safety window.
Fix:
```bash

View File

@@ -227,10 +227,12 @@ Notes:
- `OPENCLAW_LIVE_ACP_BIND_CODEX_MODEL=gpt-5.2`
- `OPENCLAW_LIVE_ACP_BIND_OPENCODE_MODEL=opencode/kimi-k2.6`
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_TRANSCRIPT=1`
- `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1`
- `OPENCLAW_LIVE_ACP_BIND_PARENT_MODEL=openai/gpt-5.2`
- Notes:
- This lane uses the gateway `chat.send` surface with admin-only synthetic originating-route fields so tests can attach message-channel context without pretending to deliver externally.
- When `OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND` is unset, the test uses the embedded `acpx` plugin's built-in agent registry for the selected ACP harness agent.
- Bound-session cron MCP creation is best-effort by default because external ACP harnesses can cancel MCP calls after the bind/image proof has passed; set `OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON=1` to make that post-bind cron probe strict.
Example:

View File

@@ -5,11 +5,7 @@ import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import chokidar, { FSWatcher } from "chokidar";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
buildCaseInsensitiveExtensionGlob,
classifyMemoryMultimodalPath,
getMemoryMultimodalExtensions,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { classifyMemoryMultimodalPath } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createSubsystemLogger,
onSessionTranscriptUpdate,
@@ -105,6 +101,9 @@ function shouldIgnoreMemoryWatchPath(
if (stats?.isDirectory?.()) {
return false;
}
if (!stats) {
return false;
}
const extension = normalizeLowercaseStringOrEmpty(path.extname(normalized));
if (extension.length === 0 || extension === ".md") {
return false;
@@ -383,16 +382,7 @@ export abstract class MemoryManagerSyncOps {
continue;
}
if (stat.isDirectory()) {
watchPaths.add(path.join(entry, "**", "*.md"));
if (this.settings.multimodal.enabled) {
for (const modality of this.settings.multimodal.modalities) {
for (const extension of getMemoryMultimodalExtensions(modality)) {
watchPaths.add(
path.join(entry, "**", buildCaseInsensitiveExtensionGlob(extension)),
);
}
}
}
watchPaths.add(entry);
continue;
}
if (
@@ -422,6 +412,7 @@ export abstract class MemoryManagerSyncOps {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.on("unlinkDir", markDirty);
}
protected ensureSessionListener() {

View File

@@ -11,12 +11,35 @@ import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js"
type WatchIgnoredFn = (watchPath: string, stats?: { isDirectory?: () => boolean }) => boolean;
const { watchMock } = vi.hoisted(() => ({
watchMock: vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
})),
}));
const { createdWatchers, watchMock } = vi.hoisted(() => {
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir";
type WatchCallback = () => void;
function createMockWatcher() {
const handlers = new Map<WatchEvent, WatchCallback[]>();
const watcher = {
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
return watcher;
}),
close: vi.fn(async () => undefined),
emit: (event: WatchEvent) => {
for (const callback of handlers.get(event) ?? []) {
callback();
}
},
};
return watcher;
}
const watchers: Array<ReturnType<typeof createMockWatcher>> = [];
return {
createdWatchers: watchers,
watchMock: vi.fn(() => {
const watcher = createMockWatcher();
watchers.push(watcher);
return watcher;
}),
};
});
vi.mock("chokidar", () => ({
default: { watch: watchMock },
@@ -69,7 +92,9 @@ describe("memory watcher config", () => {
});
afterEach(async () => {
vi.useRealTimers();
watchMock.mockClear();
createdWatchers.length = 0;
if (manager) {
await manager.close();
manager = null;
@@ -140,9 +165,10 @@ describe("memory watcher config", () => {
expect.arrayContaining([
path.join(workspaceDir, "MEMORY.md"),
path.join(workspaceDir, "memory"),
path.join(extraDir, "**", "*.md"),
extraDir,
]),
);
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
expect(options.ignoreInitial).toBe(true);
expect(options.awaitWriteFinish).toEqual({ stabilityThreshold: 25, pollInterval: 100 });
@@ -152,15 +178,19 @@ describe("memory watcher config", () => {
true,
);
expect(ignored?.(path.join(workspaceDir, "memory", ".venv", "lib", "python.md"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"))).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.tmp"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), {})).toBe(true);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.json"), undefined)).toBe(
false,
);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"))).toBe(false);
expect(ignored?.(path.join(workspaceDir, "memory", "project", "notes.md"), {})).toBe(false);
expect(
ignored?.(path.join(workspaceDir, "memory", "project"), { isDirectory: () => true }),
).toBe(false);
});
it("watches multimodal extensions with case-insensitive globs", async () => {
it("watches multimodal extra directories with filtered extensions", async () => {
await setupWatcherWorkspace({ name: "PHOTO.PNG", contents: "png" });
const cfg = createWatcherConfig({
provider: "gemini",
@@ -177,16 +207,40 @@ describe("memory watcher config", () => {
Record<string, unknown>,
];
expect(watchedPaths).toEqual(
expect.arrayContaining([
path.join(extraDir, "**", "*.[pP][nN][gG]"),
path.join(extraDir, "**", "*.[wW][aA][vV]"),
]),
expect.arrayContaining([path.join(workspaceDir, "MEMORY.md"), path.join(extraDir)]),
);
expect(watchedPaths.every((watchPath) => !watchPath.includes("*"))).toBe(true);
const ignored = options.ignored as WatchIgnoredFn | undefined;
expect(ignored).toBeTypeOf("function");
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"))).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "PHOTO.PNG"), {})).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"))).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"))).toBe(true);
expect(ignored?.(path.join(extraDir, "nested", "voice.WAV"), {})).toBe(false);
expect(ignored?.(path.join(extraDir, "nested", "metadata.json"), {})).toBe(true);
});
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"schedules watch sync on %s",
async (event) => {
await setupWatcherWorkspace({ name: "notes.md", contents: "hello" });
const cfg = createWatcherConfig();
await expectWatcherManager(cfg);
vi.useFakeTimers();
const syncSpy = vi
.spyOn(
manager as unknown as {
sync: (params?: { reason?: string }) => Promise<void>;
},
"sync",
)
.mockResolvedValue(undefined);
createdWatchers[0]?.emit(event);
await vi.advanceTimersByTimeAsync(25);
expect(syncSpy).toHaveBeenCalledWith({ reason: "watch" });
},
);
});

View File

@@ -1,4 +1,5 @@
import "./test-helpers.js";
import { EventEmitter } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -42,25 +43,57 @@ type WebAutoReplyMonitorHarness = {
controller: AbortController;
run: Promise<unknown>;
};
type MockSessionSocket = {
ev: { on: ReturnType<typeof vi.fn>; off: ReturnType<typeof vi.fn> };
ws: EventEmitter & { close: ReturnType<typeof vi.fn> };
user: { id: string };
};
export const TEST_NET_IP = "93.184.216.34";
const WEB_AUTO_REPLY_SOCKETS_KEY = Symbol.for("openclaw:webAutoReplySessionSockets");
function getSessionSockets(): MockSessionSocket[] {
const store = globalThis as Record<PropertyKey, unknown>;
if (!Array.isArray(store[WEB_AUTO_REPLY_SOCKETS_KEY])) {
store[WEB_AUTO_REPLY_SOCKETS_KEY] = [];
}
return store[WEB_AUTO_REPLY_SOCKETS_KEY] as MockSessionSocket[];
}
vi.mock("./session.js", async () => {
const actual = await vi.importActual<typeof import("./session.js")>("./session.js");
return {
...actual,
createWaSocket: vi.fn(async () => ({
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws: { close: vi.fn() },
user: { id: "123@s.whatsapp.net" },
})),
createWaSocket: vi.fn(async () => {
const ws = new EventEmitter() as MockSessionSocket["ws"];
ws.close = vi.fn();
const sock: MockSessionSocket = {
ev: {
on: vi.fn(),
off: vi.fn(),
},
ws,
user: { id: "123@s.whatsapp.net" },
};
getSessionSockets().push(sock);
return sock;
}),
waitForWaConnection: vi.fn().mockResolvedValue(undefined),
};
});
export function getLastWebAutoReplySessionSocket(): MockSessionSocket {
const last = getSessionSockets().at(-1);
if (!last) {
throw new Error("No WhatsApp Web auto-reply test socket created");
}
return last;
}
export function resetWebAutoReplySessionSockets() {
getSessionSockets().length = 0;
}
vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
appendCronStyleCurrentTimeLine: (text: string) => text,
@@ -166,6 +199,7 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
beforeEach(async () => {
vi.clearAllMocks();
resetWebAutoReplySessionSockets();
_resetBaileysMocks();
_resetLoadConfigMock();
if (opts?.pinDns) {

View File

@@ -12,6 +12,7 @@ import {
createMockWebListener,
createScriptedWebListenerFactory,
createWebListenerFactoryCapture,
getLastWebAutoReplySessionSocket,
installWebAutoReplyTestHomeHooks,
installWebAutoReplyUnitTestHooks,
makeSessionStore,
@@ -255,6 +256,92 @@ describe("web auto-reply connection", () => {
}
});
it("keeps quiet linked-device sessions open when transport frames keep arriving", async () => {
vi.useFakeTimers();
try {
const sleep = vi.fn(async () => {});
const scripted = createScriptedWebListenerFactory();
const { controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory: scripted.listenerFactory,
sleep,
heartbeatSeconds: 60,
messageTimeoutMs: 30,
watchdogCheckMs: 5,
});
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBe(1);
},
{ timeout: 250, interval: 2 },
);
const socket = getLastWebAutoReplySessionSocket();
await vi.advanceTimersByTimeAsync(20);
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
expect(scripted.getListenerCount()).toBe(1);
controller.abort();
scripted.resolveClose(0, { status: 499, isLoggedOut: false });
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
});
it("does not let transport frames mask application silence forever", async () => {
vi.useFakeTimers();
try {
const sleep = vi.fn(async () => {});
const scripted = createScriptedWebListenerFactory();
const { controller, run } = startWebAutoReplyMonitor({
monitorWebChannelFn: monitorWebChannel as never,
listenerFactory: scripted.listenerFactory,
sleep,
heartbeatSeconds: 60,
messageTimeoutMs: 30,
watchdogCheckMs: 5,
});
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBe(1);
},
{ timeout: 250, interval: 2 },
);
const socket = getLastWebAutoReplySessionSocket();
for (let elapsedMs = 0; elapsedMs < 140; elapsedMs += 20) {
socket.ws.emit("frame");
await vi.advanceTimersByTimeAsync(20);
}
await vi.waitFor(
() => {
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
},
{ timeout: 250, interval: 2 },
);
controller.abort();
scripted.resolveClose(scripted.getListenerCount() - 1, {
status: 499,
isLoggedOut: false,
error: "aborted",
});
await Promise.resolve();
await run;
} finally {
vi.useRealTimers();
}
});
it("gives a reconnected listener a fresh watchdog window", async () => {
vi.useFakeTimers();
try {

View File

@@ -280,6 +280,7 @@ export async function monitorWebChannel(
reconnectAttempts: snapshot.reconnectAttempts,
messagesHandled: snapshot.handledMessages,
lastInboundAt: snapshot.lastInboundAt,
lastTransportActivityAt: snapshot.lastTransportActivityAt,
authAgeMs,
uptimeMs: snapshot.uptimeMs,
...(minutesSinceLastMessage !== null && minutesSinceLastMessage > 30
@@ -297,20 +298,28 @@ export async function monitorWebChannel(
}
},
onWatchdogTimeout: (snapshot) => {
const watchdogBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceLastMessage = Math.floor((Date.now() - watchdogBaselineAt) / 60000);
const now = Date.now();
const transportSilentMs = now - snapshot.lastTransportActivityAt;
const appBaselineAt = snapshot.lastInboundAt ?? snapshot.startedAt;
const minutesSinceTransportActivity = Math.floor(transportSilentMs / 60000);
const minutesSinceAppActivity = Math.floor((now - appBaselineAt) / 60000);
const watchdogReason =
transportSilentMs > messageTimeoutMs ? "transport-inactive" : "app-silent";
statusController.noteWatchdogStale();
heartbeatLogger.warn(
{
connectionId: snapshot.connectionId,
minutesSinceLastMessage,
watchdogReason,
minutesSinceTransportActivity,
minutesSinceAppActivity,
lastInboundAt: snapshot.lastInboundAt ? new Date(snapshot.lastInboundAt) : null,
lastTransportActivityAt: new Date(snapshot.lastTransportActivityAt),
messagesHandled: snapshot.handledMessages,
},
"Message timeout detected - forcing reconnect",
"WhatsApp watchdog timeout detected - forcing reconnect",
);
whatsappHeartbeatLog.warn(
`No messages received in ${minutesSinceLastMessage}m - restarting connection`,
`WhatsApp watchdog timeout (${watchdogReason}) - restarting connection`,
);
},
});

View File

@@ -40,8 +40,10 @@ export type WhatsAppLiveConnection = {
heartbeat: TimerHandle | null;
watchdogTimer: TimerHandle | null;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
unregisterUnhandled: (() => void) | null;
unregisterTransportActivity: (() => void) | null;
backgroundTasks: Set<Promise<unknown>>;
closePromise: Promise<WebListenerCloseReason>;
resolveClose: (reason: WebListenerCloseReason) => void;
@@ -51,6 +53,7 @@ export type WhatsAppConnectionSnapshot = {
connectionId: string;
startedAt: number;
lastInboundAt: number | null;
lastTransportActivityAt: number;
handledMessages: number;
reconnectAttempts: number;
uptimeMs: number;
@@ -83,6 +86,12 @@ function createNeverResolvePromise<T>(): Promise<T> {
return new Promise<T>(() => {});
}
type SocketActivityEmitter = {
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
};
function createLiveConnection(params: {
connectionId: string;
sock: WASocket;
@@ -108,8 +117,10 @@ function createLiveConnection(params: {
heartbeat: null,
watchdogTimer: null,
lastInboundAt: null,
lastTransportActivityAt: Date.now(),
handledMessages: 0,
unregisterUnhandled: null,
unregisterTransportActivity: null,
backgroundTasks: new Set<Promise<unknown>>(),
closePromise,
resolveClose: resolveClosePromise,
@@ -232,6 +243,7 @@ export class WhatsAppConnectionController {
private readonly heartbeatSeconds: number;
private readonly keepAlive: boolean;
private readonly messageTimeoutMs: number;
private readonly appSilenceTimeoutMs: number;
private readonly watchdogCheckMs: number;
private readonly verbose: boolean;
private readonly abortSignal?: AbortSignal;
@@ -262,6 +274,7 @@ export class WhatsAppConnectionController {
this.keepAlive = params.keepAlive;
this.heartbeatSeconds = params.heartbeatSeconds;
this.messageTimeoutMs = params.messageTimeoutMs;
this.appSilenceTimeoutMs = Math.max(params.messageTimeoutMs, params.messageTimeoutMs * 4);
this.watchdogCheckMs = params.watchdogCheckMs;
this.reconnectPolicy = params.reconnectPolicy;
this.abortSignal = params.abortSignal;
@@ -311,6 +324,14 @@ export class WhatsAppConnectionController {
}
this.current.handledMessages += 1;
this.current.lastInboundAt = timestamp;
this.current.lastTransportActivityAt = timestamp;
}
noteTransportActivity(timestamp = Date.now()): void {
if (!this.current) {
return;
}
this.current.lastTransportActivityAt = timestamp;
}
getCurrentSnapshot(
@@ -323,6 +344,7 @@ export class WhatsAppConnectionController {
connectionId: connection.connectionId,
startedAt: connection.startedAt,
lastInboundAt: connection.lastInboundAt,
lastTransportActivityAt: connection.lastTransportActivityAt,
handledMessages: connection.handledMessages,
reconnectAttempts: this.reconnectAttempts,
uptimeMs: Date.now() - connection.startedAt,
@@ -369,6 +391,7 @@ export class WhatsAppConnectionController {
const listener = await params.createListener({ sock, connection });
connection.listener = listener;
this.current = connection;
connection.unregisterTransportActivity = this.attachTransportActivityListener(sock);
registerWhatsAppConnectionController(this.accountId, this);
this.startTimers(connection, {
onHeartbeat: params.onHeartbeat,
@@ -383,6 +406,7 @@ export class WhatsAppConnectionController {
if (connection?.unregisterUnhandled) {
connection.unregisterUnhandled();
}
connection?.unregisterTransportActivity?.();
throw err;
}
}
@@ -515,6 +539,7 @@ export class WhatsAppConnectionController {
this.socketRef.current = null;
}
connection.unregisterUnhandled?.();
connection.unregisterTransportActivity?.();
if (connection.heartbeat) {
clearInterval(connection.heartbeat);
}
@@ -563,9 +588,14 @@ export class WhatsAppConnectionController {
}, this.heartbeatSeconds * 1000);
connection.watchdogTimer = setInterval(() => {
const baselineAt = connection.lastInboundAt ?? connection.startedAt;
const staleForMs = Date.now() - baselineAt;
if (staleForMs <= this.messageTimeoutMs) {
const now = Date.now();
const transportStaleForMs = now - connection.lastTransportActivityAt;
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
const appSilentForMs = now - appBaselineAt;
if (
transportStaleForMs <= this.messageTimeoutMs &&
appSilentForMs <= this.appSilenceTimeoutMs
) {
return;
}
const snapshot = this.getCurrentSnapshot(connection);
@@ -581,6 +611,24 @@ export class WhatsAppConnectionController {
}, this.watchdogCheckMs);
}
private attachTransportActivityListener(sock: WASocket): (() => void) | null {
const ws = sock.ws as SocketActivityEmitter | undefined;
if (!ws || typeof ws.on !== "function") {
return null;
}
const noteActivity = () => this.noteTransportActivity();
ws.on("frame", noteActivity);
return () => {
if (typeof ws.off === "function") {
ws.off("frame", noteActivity);
return;
}
ws.removeListener?.("frame", noteActivity);
};
}
private stopDisconnectRetries(): void {
if (!this.disconnectRetryController.signal.aborted) {
this.disconnectRetryController.abort();

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.26",
"version": "2026.4.25-beta.9",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",

View File

@@ -18,7 +18,7 @@ $ErrorActionPreference = "Stop"
$ACCENT = "`e[38;2;255;77;77m" # coral-bright
$SUCCESS = "`e[38;2;0;229;204m" # cyan-bright
$WARN = "`e[38;2;255;176;32m" # amber
$ERROR = "`e[38;2;230;57;70m" # coral-mid
$ERROR_COLOR = "`e[38;2;230;57;70m" # coral-mid
$MUTED = "`e[38;2;90;100;128m" # text-muted
$NC = "`e[0m" # No Color
@@ -27,7 +27,7 @@ function Write-Host {
$msg = switch ($Level) {
"success" { "$SUCCESS$NC $Message" }
"warn" { "$WARN!$NC $Message" }
"error" { "$ERROR$NC $Message" }
"error" { "$ERROR_COLOR$NC $Message" }
default { "$MUTED·$NC $Message" }
}
Microsoft.PowerShell.Utility\Write-Host $msg

View File

@@ -148,6 +148,7 @@ exec "\$script_dir/claude-real" "\$@"
WRAP
chmod +x "$NPM_CONFIG_PREFIX/bin/claude"
fi
export CLAUDE_CODE_EXECUTABLE="$NPM_CONFIG_PREFIX/bin/claude"
claude auth status || true
;;
codex)
@@ -162,8 +163,8 @@ WRAP
fi
droid --version
if [ -z "${FACTORY_API_KEY:-}" ]; then
echo "Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
exit 1
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
exit 0
fi
;;
gemini)
@@ -262,6 +263,16 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do
DOCKER_AUTH_PRESTAGED=1
fi
if [[ "$ACP_AGENT" == "droid" && -z "${FACTORY_API_KEY:-}" ]]; then
echo "==> Run ACP bind live test in Docker"
echo "==> Agent: $ACP_AGENT"
echo "==> Profile file: $PROFILE_STATUS"
echo "==> Auth dirs: ${AUTH_DIRS_CSV:-none}"
echo "==> Auth files: ${AUTH_FILES_CSV:-none}"
echo "SKIP: Droid Docker ACP bind requires FACTORY_API_KEY; Factory OAuth/keyring auth in ~/.factory is not portable into the container." >&2
continue
fi
EXTERNAL_AUTH_MOUNTS=()
if ((${#AUTH_DIRS[@]} > 0)); then
for auth_dir in "${AUTH_DIRS[@]}"; do

View File

@@ -707,6 +707,55 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("jumps directly to a later live-session model switch candidate (#57471)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-4.1-mini",
fallbacks: [
"anthropic/claude-haiku-3-5",
"anthropic/claude-sonnet-4-6",
"openrouter/deepseek-chat",
],
},
},
},
});
const switchError = new LiveSessionModelSwitchError({
provider: "anthropic",
model: "claude-sonnet-4-6",
});
const run = vi.fn(async (provider: string, model: string) => {
if (provider === "openai" && model === "gpt-4.1-mini") {
throw switchError;
}
if (provider === "anthropic" && model === "claude-sonnet-4-6") {
return "ok";
}
throw new Error(`unexpected fallback candidate: ${provider}/${model}`);
});
const onError = vi.fn();
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-4.1-mini",
run,
onError,
});
expect(result.result).toBe("ok");
expect(result.provider).toBe("anthropic");
expect(result.model).toBe("claude-sonnet-4-6");
expect(result.attempts).toEqual([]);
expect(onError).not.toHaveBeenCalled();
expect(run.mock.calls).toEqual([
["openai", "gpt-4.1-mini"],
["anthropic", "claude-sonnet-4-6"],
]);
});
it("falls back on auth errors", async () => {
await expectFallsBackToHaiku({
provider: "openai",

View File

@@ -326,6 +326,18 @@ function recordFailedCandidateAttempt(params: {
});
}
function findLaterLiveSessionModelSwitchCandidateIndex(params: {
error: LiveSessionModelSwitchError;
candidates: ModelCandidate[];
currentIndex: number;
}): number | null {
const targetKey = modelKey(params.error.provider, params.error.model);
const targetIndex = params.candidates.findIndex(
(candidate) => modelKey(candidate.provider, candidate.model) === targetKey,
);
return targetIndex > params.currentIndex ? targetIndex : null;
}
function throwFallbackFailureSummary(params: {
attempts: FallbackAttempt[];
candidates: ModelCandidate[];
@@ -924,6 +936,16 @@ export async function runWithModelFallback<T>(params: {
// instead of re-throwing and triggering infinite retry loops in the
// outer runner. (#58466)
if (err instanceof LiveSessionModelSwitchError) {
const liveSwitchTargetIndex = findLaterLiveSessionModelSwitchCandidateIndex({
error: err,
candidates,
currentIndex: i,
});
if (liveSwitchTargetIndex !== null) {
i = liveSwitchTargetIndex - 1;
continue;
}
const switchMsg = err.message;
const switchNormalized = new FailoverError(switchMsg, {
reason: "overloaded",

View File

@@ -700,6 +700,36 @@ describe("applyExtraParamsToAgent", () => {
});
});
it("keeps OpenAI Responses web_search compatible when thinking is minimal", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
reasoning: true,
} as Model<"openai-responses">,
payload: {
model: "gpt-5",
input: [],
tools: [
{
type: "function",
name: "web_search",
description: "Search the web",
parameters: { type: "object", properties: {} },
},
],
reasoning: { effort: "low", summary: "auto" },
},
thinkingLevel: "minimal",
});
expect(payload.reasoning).toEqual({ effort: "low", summary: "auto" });
});
it("strips disabled reasoning payloads for proxied OpenAI responses routes", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -159,6 +159,32 @@ describe("createOpenAIThinkingLevelWrapper", () => {
}
});
it("raises minimal reasoning for web_search on loopback Responses routes", () => {
const payloads: Array<Record<string, unknown>> = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {
reasoning: { effort: "minimal", summary: "auto" },
tools: [{ type: "function", name: "web_search" }],
};
options?.onPayload?.(payload, _model);
payloads.push(structuredClone(payload));
return createAssistantMessageEventStream();
};
const wrapped = createOpenAIThinkingLevelWrapper(baseStreamFn, "minimal");
void wrapped(
{
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "http://127.0.0.1:19191/v1",
} as Model<"openai-responses">,
{ messages: [] },
{},
);
expect(payloads[0]?.reasoning).toEqual({ effort: "low", summary: "auto" });
});
it.each([
{
api: "openai-responses",

View File

@@ -9,6 +9,7 @@ import {
resolveCodexNativeSearchActivation,
} from "../codex-native-web-search.js";
import { flattenCompletionMessagesToStringContent } from "../openai-completions-string-content.js";
import { resolveOpenAIReasoningEffortForModel } from "../openai-reasoning-effort.js";
import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
@@ -85,6 +86,66 @@ function shouldFlattenOpenAICompletionMessages(model: {
return model.api === "openai-completions" && compat?.requiresStringContent === true;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function hasResponsesWebSearchTool(tools: unknown): boolean {
if (!Array.isArray(tools)) {
return false;
}
return tools.some((tool) => {
if (!isRecord(tool)) {
return false;
}
if (tool.type === "web_search") {
return true;
}
if (tool.type === "function" && tool.name === "web_search") {
return true;
}
const fn = tool.function;
return isRecord(fn) && fn.name === "web_search";
});
}
function resolveOpenAIThinkingPayloadEffort(params: {
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
payloadObj: Record<string, unknown>;
thinkingLevel: ThinkLevel;
}) {
const mapped = mapThinkingLevelToReasoningEffort(params.thinkingLevel);
if (mapped !== "minimal" || !hasResponsesWebSearchTool(params.payloadObj.tools)) {
return mapped;
}
return (
resolveOpenAIReasoningEffortForModel({
model: params.model,
effort: "low",
}) ?? mapped
);
}
function raiseMinimalReasoningForResponsesWebSearchPayload(params: {
model: { provider?: unknown; id?: unknown; baseUrl?: unknown; api?: unknown; compat?: unknown };
payloadObj: Record<string, unknown>;
}): void {
const reasoning = params.payloadObj.reasoning;
if (!isRecord(reasoning) || reasoning.effort !== "minimal") {
return;
}
if (!hasResponsesWebSearchTool(params.payloadObj.tools)) {
return;
}
const nextEffort = resolveOpenAIReasoningEffortForModel({
model: params.model,
effort: "low",
});
if (nextEffort && nextEffort !== "minimal" && nextEffort !== "none") {
reasoning.effort = nextEffort;
}
}
function normalizeOpenAIServiceTier(value: unknown): OpenAIServiceTier | undefined {
if (typeof value !== "string") {
return undefined;
@@ -240,7 +301,12 @@ export function createOpenAIThinkingLevelWrapper(
}
return (model, context, options) => {
if (!shouldApplyOpenAIReasoningCompatibility(model)) {
return underlying(model, context, options);
if (thinkingLevel === "off") {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
});
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
const existingReasoning = payloadObj.reasoning;
@@ -251,8 +317,13 @@ export function createOpenAIThinkingLevelWrapper(
return;
}
const reasoningEffort = resolveOpenAIThinkingPayloadEffort({
model,
payloadObj,
thinkingLevel,
});
if (existingReasoning === "none") {
payloadObj.reasoning = { effort: mapThinkingLevelToReasoningEffort(thinkingLevel) };
payloadObj.reasoning = { effort: reasoningEffort };
return;
}
if (
@@ -260,8 +331,8 @@ export function createOpenAIThinkingLevelWrapper(
typeof existingReasoning === "object" &&
!Array.isArray(existingReasoning)
) {
(existingReasoning as Record<string, unknown>).effort =
mapThinkingLevelToReasoningEffort(thinkingLevel);
(existingReasoning as Record<string, unknown>).effort = reasoningEffort;
raiseMinimalReasoningForResponsesWebSearchPayload({ model, payloadObj });
}
});
};

View File

@@ -117,6 +117,12 @@ describe("shouldCreateBundleMcpRuntimeForAttempt", () => {
toolsAllow: ["memory_search", "memory_get"],
}),
).toBe(false);
expect(
shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled: true,
toolsAllow: ["bundle-mcp"],
}),
).toBe(true);
expect(
shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled: true,

View File

@@ -490,7 +490,9 @@ export function shouldCreateBundleMcpRuntimeForAttempt(params: {
if (!params.toolsAllow || params.toolsAllow.length === 0) {
return true;
}
return params.toolsAllow.some((toolName) => toolName.includes(TOOL_NAME_SEPARATOR));
return params.toolsAllow.some(
(toolName) => toolName === "bundle-mcp" || toolName.includes(TOOL_NAME_SEPARATOR),
);
}
function collectAttemptExplicitToolAllowlistSources(params: {

View File

@@ -1,11 +1,34 @@
import os from "node:os";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { SkillsChangeEvent } from "./refresh.js";
const watchMock = vi.fn(() => ({
on: vi.fn(),
close: vi.fn(async () => undefined),
}));
type WatchEvent = "add" | "change" | "unlink" | "unlinkDir" | "error";
type WatchCallback = (watchPath: string) => void;
function createMockWatcher() {
const handlers = new Map<WatchEvent, WatchCallback[]>();
const watcher = {
on: vi.fn((event: WatchEvent, callback: WatchCallback) => {
handlers.set(event, [...(handlers.get(event) ?? []), callback]);
return watcher;
}),
close: vi.fn(async () => undefined),
emit: (event: WatchEvent, watchPath: string) => {
for (const callback of handlers.get(event) ?? []) {
callback(watchPath);
}
},
};
return watcher;
}
const createdWatchers: Array<ReturnType<typeof createMockWatcher>> = [];
const watchMock = vi.fn(() => {
const watcher = createMockWatcher();
createdWatchers.push(watcher);
return watcher;
});
let refreshModule: typeof import("./refresh.js");
@@ -24,13 +47,15 @@ describe("ensureSkillsWatcher", () => {
beforeEach(() => {
watchMock.mockClear();
createdWatchers.length = 0;
});
afterEach(async () => {
vi.useRealTimers();
await refreshModule.resetSkillsRefreshForTest();
});
it("ignores node_modules, dist, .git, and Python venvs by default", async () => {
it("watches skill roots and filters non-skill churn", async () => {
refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" });
expect(watchMock).toHaveBeenCalledTimes(1);
@@ -40,49 +65,64 @@ describe("ensureSkillsWatcher", () => {
const targets = firstCall?.[0] ?? [];
const opts = firstCall?.[1] ?? {};
expect(opts.ignored).toBe(refreshModule.DEFAULT_SKILLS_WATCH_IGNORED);
expect(opts.ignored).toBe(refreshModule.shouldIgnoreSkillsWatchPath);
const posix = (p: string) => p.replaceAll("\\", "/");
expect(targets).toEqual(
expect.arrayContaining([
posix(path.join("/tmp/workspace", "skills", "SKILL.md")),
posix(path.join("/tmp/workspace", "skills", "*", "SKILL.md")),
posix(path.join("/tmp/workspace", ".agents", "skills", "SKILL.md")),
posix(path.join("/tmp/workspace", ".agents", "skills", "*", "SKILL.md")),
posix(path.join(os.homedir(), ".agents", "skills", "SKILL.md")),
posix(path.join(os.homedir(), ".agents", "skills", "*", "SKILL.md")),
posix(path.join("/tmp/workspace", "skills")),
posix(path.join("/tmp/workspace", ".agents", "skills")),
posix(path.join(os.homedir(), ".agents", "skills")),
]),
);
expect(targets.every((target) => target.includes("SKILL.md"))).toBe(true);
const ignored = refreshModule.DEFAULT_SKILLS_WATCH_IGNORED;
expect(targets.every((target) => !target.includes("*"))).toBe(true);
const ignored = refreshModule.shouldIgnoreSkillsWatchPath;
// Node/JS paths
expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/dist/index.js"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.git/config"))).toBe(true);
expect(ignored("/tmp/workspace/skills/node_modules/pkg/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/dist/index.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.git/config")).toBe(true);
// Python virtual environments and caches
expect(ignored.some((re) => re.test("/tmp/workspace/skills/scripts/.venv/bin/python"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/venv/lib/python3.10/site.py"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/__pycache__/module.pyc"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.mypy_cache/3.10/foo.json"))).toBe(
true,
);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.pytest_cache/v/cache"))).toBe(true);
expect(ignored("/tmp/workspace/skills/scripts/.venv/bin/python")).toBe(true);
expect(ignored("/tmp/workspace/skills/venv/lib/python3.10/site.py")).toBe(true);
expect(ignored("/tmp/workspace/skills/__pycache__/module.pyc")).toBe(true);
expect(ignored("/tmp/workspace/skills/.mypy_cache/3.10/foo.json")).toBe(true);
expect(ignored("/tmp/workspace/skills/.pytest_cache/v/cache")).toBe(true);
// Build artifacts and caches
expect(ignored.some((re) => re.test("/tmp/workspace/skills/build/output.js"))).toBe(true);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/.cache/data.json"))).toBe(true);
expect(ignored("/tmp/workspace/skills/build/output.js")).toBe(true);
expect(ignored("/tmp/workspace/skills/.cache/data.json")).toBe(true);
// Should NOT ignore normal skill files
expect(ignored.some((re) => re.test("/tmp/.hidden/skills/index.md"))).toBe(false);
expect(ignored.some((re) => re.test("/tmp/workspace/skills/my-skill/SKILL.md"))).toBe(false);
expect(ignored("/tmp/.hidden/skills/index.md")).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill", { isDirectory: () => true })).toBe(false);
expect(ignored("/tmp/workspace/skills/my-skill/README.md", {})).toBe(true);
expect(ignored("/tmp/workspace/skills/my-skill/SKILL.md", {})).toBe(false);
});
it.each(["add", "change", "unlink", "unlinkDir"] as const)(
"refreshes skills snapshots on %s",
async (event) => {
vi.useFakeTimers();
const seen: SkillsChangeEvent[] = [];
refreshModule.registerSkillsChangeListener((change) => {
seen.push(change);
});
refreshModule.ensureSkillsWatcher({
workspaceDir: "/tmp/workspace",
config: { skills: { load: { watchDebounceMs: 10 } } },
});
createdWatchers[0]?.emit(event, "/tmp/workspace/skills/demo/SKILL.md");
await vi.advanceTimersByTimeAsync(10);
expect(seen).toEqual([
{
workspaceDir: "/tmp/workspace",
reason: "watch",
changedPath: "/tmp/workspace/skills/demo/SKILL.md",
},
]);
},
);
});

View File

@@ -72,26 +72,36 @@ function resolveWatchPaths(workspaceDir: string, config?: OpenClawConfig): strin
return paths;
}
function toWatchGlobRoot(raw: string): string {
// Chokidar treats globs as POSIX-ish patterns. Normalize Windows separators
// so `*` works consistently across platforms.
return raw.replaceAll("\\", "/").replace(/\/+$/, "");
function toWatchRoot(raw: string): string {
const normalized = raw.replaceAll("\\", "/");
return normalized.replace(/\/+$/, "") || normalized;
}
function resolveWatchTargets(workspaceDir: string, config?: OpenClawConfig): string[] {
// Skills are defined by SKILL.md; watch only those files to avoid traversing
// or watching unrelated large trees (e.g. datasets) that can exhaust FDs.
const targets = new Set<string>();
for (const root of resolveWatchPaths(workspaceDir, config)) {
const globRoot = toWatchGlobRoot(root);
// Some configs point directly at a skill folder.
targets.add(`${globRoot}/SKILL.md`);
// Standard layout: <skillsRoot>/<skillName>/SKILL.md
targets.add(`${globRoot}/*/SKILL.md`);
targets.add(toWatchRoot(root));
}
return Array.from(targets).toSorted();
}
export function shouldIgnoreSkillsWatchPath(
watchPath: string,
stats?: { isDirectory?: () => boolean },
): boolean {
if (DEFAULT_SKILLS_WATCH_IGNORED.some((re) => re.test(watchPath))) {
return true;
}
if (stats?.isDirectory?.()) {
return false;
}
if (!stats) {
return false;
}
const normalized = watchPath.replaceAll("\\", "/");
return path.posix.basename(normalized) !== "SKILL.md";
}
export function ensureSkillsWatcher(params: { workspaceDir: string; config?: OpenClawConfig }) {
const workspaceDir = params.workspaceDir.trim();
if (!workspaceDir) {
@@ -135,9 +145,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
stabilityThreshold: debounceMs,
pollInterval: 100,
},
// Avoid FD exhaustion on macOS when a workspace contains huge trees.
// This watcher only needs to react to SKILL.md changes.
ignored: DEFAULT_SKILLS_WATCH_IGNORED,
ignored: shouldIgnoreSkillsWatchPath,
});
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
@@ -162,6 +170,7 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
watcher.on("add", (p) => schedule(p));
watcher.on("change", (p) => schedule(p));
watcher.on("unlink", (p) => schedule(p));
watcher.on("unlinkDir", (p) => schedule(p));
watcher.on("error", (err) => {
log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
});

View File

@@ -3392,6 +3392,95 @@ describe("dispatchReplyFromConfig", () => {
);
});
it("poisons inbound dedupe when dispatch fails after a block reply", async () => {
setNoAbort();
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550125",
To: "whatsapp:+15555550125",
AccountId: "default",
MessageSid: "msg-dup-block-error",
SessionKey: "agent:main:whatsapp:direct:+15555550125",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
});
const firstDispatcher = createDispatcher();
const replyResolver = vi.fn(
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
await opts?.onBlockReply?.({ text: "partial answer" });
throw new Error("provider failed after block");
},
);
await expect(
dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: firstDispatcher,
replyResolver,
}),
).rejects.toThrow("provider failed after block");
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver,
});
expect(firstDispatcher.sendBlockReply).toHaveBeenCalledWith({ text: "partial answer" });
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("poisons inbound dedupe when dispatch fails after a suppressed tool result", async () => {
setNoAbort();
sessionStoreMocks.currentEntry = {
sessionId: "s1",
updatedAt: 0,
sendPolicy: "deny",
};
const ctx = buildTestCtx({
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550126",
To: "whatsapp:+15555550126",
AccountId: "default",
MessageSid: "msg-dup-tool-error",
SessionKey: "agent:main:whatsapp:direct:+15555550126",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
});
const firstDispatcher = createDispatcher();
const replyResolver = vi.fn(
async (_ctx: MsgContext, opts?: GetReplyOptions): Promise<ReplyPayload | undefined> => {
await opts?.onToolResult?.({ text: "tool touched external state" });
throw new Error("provider failed after tool");
},
);
await expect(
dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: firstDispatcher,
replyResolver,
}),
).rejects.toThrow("provider failed after tool");
await dispatchReplyFromConfig({
ctx,
cfg: emptyConfig,
dispatcher: createDispatcher(),
replyResolver,
});
expect(firstDispatcher.sendToolResult).not.toHaveBeenCalled();
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("passes configOverride to replyResolver when provided", async () => {
setNoAbort();
const cfg = emptyConfig;

View File

@@ -343,6 +343,10 @@ export async function dispatchReplyFromConfig(
recordProcessed("skipped", { reason: "duplicate" });
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
}
let inboundDedupeReplayUnsafe = false;
const markInboundDedupeReplayUnsafe = () => {
inboundDedupeReplayUnsafe = true;
};
const initialSessionStoreEntry = resolveSessionStoreLookup(ctx, cfg);
const boundAcpDispatchSessionKey = resolveBoundAcpDispatchSessionKey({ ctx, cfg });
@@ -473,6 +477,7 @@ export async function dispatchReplyFromConfig(
if (!shouldRouteToOriginating || !routeReplyChannel || !routeReplyTo || !routeReplyRuntime) {
return null;
}
markInboundDedupeReplayUnsafe();
return await routeReplyRuntime.routeReply({
payload,
channel: routeReplyChannel,
@@ -538,6 +543,7 @@ export async function dispatchReplyFromConfig(
}
return result.ok;
}
markInboundDedupeReplayUnsafe();
return mode === "additive"
? dispatcher.sendToolResult(payload)
: dispatcher.sendFinalReply(payload);
@@ -721,6 +727,7 @@ export async function dispatchReplyFromConfig(
);
}
} else {
markInboundDedupeReplayUnsafe();
queuedFinal = dispatcher.sendFinalReply(payload);
}
} else {
@@ -744,6 +751,9 @@ export async function dispatchReplyFromConfig(
const sendFinalPayload = async (
payload: ReplyPayload,
): Promise<{ queuedFinal: boolean; routedFinalCount: number }> => {
if (resolveSendableOutboundReplyParts(payload).hasContent) {
markInboundDedupeReplayUnsafe();
}
const ttsPayload = await maybeApplyTtsToReplyPayload({
payload,
cfg,
@@ -767,6 +777,7 @@ export async function dispatchReplyFromConfig(
routedFinalCount: result.ok ? 1 : 0,
};
}
markInboundDedupeReplayUnsafe();
return {
queuedFinal: dispatcher.sendFinalReply(normalizedPayload),
routedFinalCount: 0,
@@ -898,6 +909,7 @@ export async function dispatchReplyFromConfig(
await sendPayloadAsync(payload, undefined, false);
return;
}
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(payload);
};
const sendPlanUpdate = async (payload: {
@@ -914,6 +926,7 @@ export async function dispatchReplyFromConfig(
await sendPayloadAsync(replyPayload, undefined, false);
return;
}
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(replyPayload);
};
const summarizeApprovalLabel = (payload: {
@@ -1019,6 +1032,7 @@ export async function dispatchReplyFromConfig(
suppressTyping: typing.suppressTyping,
onToolResult: (payload: ReplyPayload) => {
const run = async () => {
markInboundDedupeReplayUnsafe();
await onToolResultFromReplyOptions?.(payload);
if (suppressDelivery) {
return;
@@ -1055,12 +1069,14 @@ export async function dispatchReplyFromConfig(
if (shouldRouteToOriginating) {
await sendPayloadAsync(deliveryPayload, undefined, false);
} else {
markInboundDedupeReplayUnsafe();
dispatcher.sendToolResult(deliveryPayload);
}
};
return run();
},
onPlanUpdate: async (payload) => {
markInboundDedupeReplayUnsafe();
await onPlanUpdateFromReplyOptions?.(payload);
if (payload.phase !== "update" || suppressDefaultToolProgressMessages) {
return;
@@ -1068,6 +1084,7 @@ export async function dispatchReplyFromConfig(
await sendPlanUpdate({ explanation: payload.explanation, steps: payload.steps });
},
onApprovalEvent: async (payload) => {
markInboundDedupeReplayUnsafe();
await onApprovalEventFromReplyOptions?.(payload);
if (payload.phase !== "requested" || suppressDefaultToolProgressMessages) {
return;
@@ -1083,6 +1100,7 @@ export async function dispatchReplyFromConfig(
await maybeSendWorkingStatus(label);
},
onPatchSummary: async (payload) => {
markInboundDedupeReplayUnsafe();
await onPatchSummaryFromReplyOptions?.(payload);
if (payload.phase !== "end" || suppressDefaultToolProgressMessages) {
return;
@@ -1095,6 +1113,12 @@ export async function dispatchReplyFromConfig(
},
onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => {
const run = async () => {
if (
payload.isReasoning !== true &&
resolveSendableOutboundReplyParts(payload).hasContent
) {
markInboundDedupeReplayUnsafe();
}
if (suppressDelivery) {
return;
}
@@ -1156,6 +1180,7 @@ export async function dispatchReplyFromConfig(
if (shouldRouteToOriginating) {
await sendPayloadAsync(normalizedPayload, context?.abortSignal, false);
} else {
markInboundDedupeReplayUnsafe();
dispatcher.sendBlockReply(normalizedPayload);
}
};
@@ -1268,6 +1293,7 @@ export async function dispatchReplyFromConfig(
);
}
} else {
markInboundDedupeReplayUnsafe();
const didQueue = dispatcher.sendFinalReply(normalizedTtsOnlyPayload);
queuedFinal = didQueue || queuedFinal;
}
@@ -1293,7 +1319,11 @@ export async function dispatchReplyFromConfig(
return { queuedFinal, counts };
} catch (err) {
if (inboundDedupeClaim.status === "claimed") {
releaseInboundDedupe(inboundDedupeClaim.key);
if (inboundDedupeReplayUnsafe) {
commitInboundDedupe(inboundDedupeClaim.key);
} else {
releaseInboundDedupe(inboundDedupeClaim.key);
}
}
recordProcessed("error", { error: String(err) });
markIdle("message_error");

View File

@@ -72,4 +72,33 @@ describe("inbound dedupe", () => {
inboundB.resetInboundDedupe();
}
});
it("shares claim/commit state across distinct module instances", async () => {
const inboundA = await importFreshModule<typeof import("./inbound-dedupe.js")>(
import.meta.url,
"./inbound-dedupe.js?scope=commit-a",
);
const inboundB = await importFreshModule<typeof import("./inbound-dedupe.js")>(
import.meta.url,
"./inbound-dedupe.js?scope=commit-b",
);
inboundA.resetInboundDedupe();
inboundB.resetInboundDedupe();
try {
const firstClaim = inboundA.claimInboundDedupe(sharedInboundContext);
expect(firstClaim).toMatchObject({ status: "claimed" });
if (firstClaim.status !== "claimed") {
throw new Error("expected claimed inbound dedupe result");
}
inboundA.commitInboundDedupe(firstClaim.key);
expect(inboundB.claimInboundDedupe(sharedInboundContext)).toMatchObject({
status: "duplicate",
});
} finally {
inboundA.resetInboundDedupe();
inboundB.resetInboundDedupe();
}
});
});

View File

@@ -1502,6 +1502,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
minLength: 1,
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
label: {
type: "string",
},
description: {
type: "string",
},
provider: {
type: "string",
minLength: 1,
},
fallbackPolicy: {
anyOf: [
{
type: "string",
const: "preserve-persona",
},
{
type: "string",
const: "provider-defaults",
},
{
type: "string",
const: "fail",
},
],
},
prompt: {
type: "object",
properties: {
profile: {
type: "string",
},
scene: {
type: "string",
},
sampleContext: {
type: "string",
},
style: {
type: "string",
},
accent: {
type: "string",
},
pacing: {
type: "string",
},
constraints: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
{
type: "boolean",
},
{
type: "null",
},
{
type: "array",
items: {},
},
{
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
],
},
},
},
},
additionalProperties: false,
},
},
summaryModel: {
type: "string",
},
@@ -2682,6 +2857,181 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
type: "string",
minLength: 1,
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
label: {
type: "string",
},
description: {
type: "string",
},
provider: {
type: "string",
minLength: 1,
},
fallbackPolicy: {
anyOf: [
{
type: "string",
const: "preserve-persona",
},
{
type: "string",
const: "provider-defaults",
},
{
type: "string",
const: "fail",
},
],
},
prompt: {
type: "object",
properties: {
profile: {
type: "string",
},
scene: {
type: "string",
},
sampleContext: {
type: "string",
},
style: {
type: "string",
},
accent: {
type: "string",
},
pacing: {
type: "string",
},
constraints: {
type: "array",
items: {
type: "string",
},
},
},
additionalProperties: false,
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{
type: "string",
},
{
oneOf: [
{
type: "object",
properties: {
source: {
type: "string",
const: "env",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
pattern: "^[A-Z][A-Z0-9_]{0,127}$",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "file",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: {
type: "string",
const: "exec",
},
provider: {
type: "string",
pattern: "^[a-z][a-z0-9_-]{0,63}$",
},
id: {
type: "string",
},
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
additionalProperties: {
anyOf: [
{
type: "string",
},
{
type: "number",
},
{
type: "boolean",
},
{
type: "null",
},
{
type: "array",
items: {},
},
{
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
],
},
},
},
},
additionalProperties: false,
},
},
summaryModel: {
type: "string",
},
@@ -3792,6 +4142,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
default: true,
type: "boolean",
},
tts: {
type: "object",
properties: {
auto: {
type: "string",
enum: ["off", "always", "inbound", "tagged"],
},
enabled: {
type: "boolean",
},
mode: {
type: "string",
enum: ["final", "all"],
},
provider: {
type: "string",
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
summaryModel: {
type: "string",
},
modelOverrides: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
prefsPath: {
type: "string",
},
maxTextLength: {
type: "integer",
minimum: 1,
maximum: 9007199254740991,
},
timeoutMs: {
type: "integer",
minimum: 1000,
maximum: 120000,
},
},
additionalProperties: false,
},
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
@@ -4345,6 +4767,78 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
resolveSenderNames: {
type: "boolean",
},
tts: {
type: "object",
properties: {
auto: {
type: "string",
enum: ["off", "always", "inbound", "tagged"],
},
enabled: {
type: "boolean",
},
mode: {
type: "string",
enum: ["final", "all"],
},
provider: {
type: "string",
},
persona: {
type: "string",
},
personas: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
summaryModel: {
type: "string",
},
modelOverrides: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
providers: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {
type: "object",
propertyNames: {
type: "string",
},
additionalProperties: {},
},
},
prefsPath: {
type: "string",
},
maxTextLength: {
type: "integer",
minimum: 1,
maximum: 9007199254740991,
},
timeoutMs: {
type: "integer",
minimum: 1000,
maximum: 120000,
},
},
additionalProperties: false,
},
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],

View File

@@ -28617,6 +28617,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.26",
version: "2026.4.25-beta.9",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -437,6 +437,31 @@ describe("channel-health-monitor", () => {
monitor.stop();
});
it("counts failed restart attempts toward cooldown and hourly caps", async () => {
const manager = createSnapshotManager(
{
discord: {
default: managedStoppedAccount("keeps crashing"),
},
},
{
startChannel: vi.fn(async () => {
throw new Error("startup failed");
}),
},
);
const monitor = startDefaultMonitor(manager, {
checkIntervalMs: 1_000,
cooldownCycles: 1,
maxRestartsPerHour: 1,
});
await vi.advanceTimersByTimeAsync(5_001);
expect(manager.startChannel).toHaveBeenCalledTimes(1);
monitor.stop();
});
it("runs checks single-flight when restart work is still in progress", async () => {
let releaseStart: (() => void) | undefined;
const startGate = new Promise<void>((resolve) => {

View File

@@ -157,15 +157,16 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
log.info?.(`[${channelId}:${accountId}] health-monitor: restarting (reason: ${reason})`);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
try {
if (status.running) {
await channelManager.stopChannel(channelId as ChannelId, accountId);
}
channelManager.resetRestartAttempts(channelId as ChannelId, accountId);
await channelManager.startChannel(channelId as ChannelId, accountId);
record.lastRestartAt = now;
record.restartsThisHour.push({ at: now });
restartRecords.set(key, record);
} catch (err) {
log.error?.(
`[${channelId}:${accountId}] health-monitor: restart failed: ${String(err)}`,

View File

@@ -36,6 +36,9 @@ const describeLive = LIVE && ACP_BIND_LIVE ? describe : describe.skip;
const CONNECT_TIMEOUT_MS = 90_000;
const LIVE_TIMEOUT_MS = 240_000;
const ACP_CRON_MCP_PROBE_MAX_ATTEMPTS = 2;
const ACP_CRON_MCP_PROBE_VERIFY_POLLS = 5;
const ACP_CRON_MCP_PROBE_VERIFY_POLL_MS = 1_000;
const DEFAULT_LIVE_CODEX_MODEL = "gpt-5.5";
const DEFAULT_LIVE_PARENT_MODEL = "openai/gpt-5.4";
type LiveAcpAgent = "claude" | "codex" | "droid" | "gemini" | "opencode";
@@ -150,6 +153,10 @@ function shouldRequireBoundAssistantTranscript(liveAgent: LiveAcpAgent): boolean
);
}
function shouldRequireCronMcpProbe(): boolean {
return isTruthyEnvValue(process.env.OPENCLAW_LIVE_ACP_BIND_REQUIRE_CRON);
}
function normalizeOpenAiModelRef(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
@@ -287,24 +294,30 @@ async function bindConversationAndWait(params: {
doctor?: () => Promise<{ message?: string; details?: string[] }>;
}
| undefined;
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
if (!(backend?.healthy?.() ?? false)) {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),
details: [],
}));
logLiveStep(
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
report.details?.length ? ` (${report.details.join("; ")})` : ""
}`,
);
const backendUnavailable = !backend || (backend.healthy && !backend.healthy());
if (backendUnavailable) {
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
const backendReadyAfterProbe = backend && (!backend.healthy || backend.healthy());
if (backendReadyAfterProbe) {
logLiveStep(`acpx backend became healthy before bind attempt ${attempt}`);
} else {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),
details: [],
}));
logLiveStep(
`acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${
report.details?.length ? ` (${report.details.join("; ")})` : ""
}`,
);
}
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
await sleep(5_000);
continue;
}
logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`);
await sleep(5_000);
continue;
}
await sendChatAndWait({
@@ -463,6 +476,25 @@ async function waitForAssistantTurn(params: {
);
}
async function pollCronJobVisibleViaCli(params: {
port: number;
token: string;
env: NodeJS.ProcessEnv;
expectedName: string;
expectedMessage: string;
}): Promise<{ job?: Awaited<ReturnType<typeof assertCronJobVisibleViaCli>>; pollsUsed: number }> {
for (let verifyAttempt = 0; verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS; verifyAttempt += 1) {
const job = await assertCronJobVisibleViaCli(params);
if (job) {
return { job, pollsUsed: verifyAttempt + 1 };
}
if (verifyAttempt < ACP_CRON_MCP_PROBE_VERIFY_POLLS - 1) {
await sleep(ACP_CRON_MCP_PROBE_VERIFY_POLL_MS);
}
}
return { pollsUsed: ACP_CRON_MCP_PROBE_VERIFY_POLLS };
}
describeLive("gateway live (ACP bind)", () => {
it(
"binds a synthetic Slack DM conversation to a live ACP session and reroutes the next turn",
@@ -852,9 +884,10 @@ describeLive("gateway live (ACP bind)", () => {
agentId: liveAgent,
sessionKey: spawnedSessionKey,
});
const requireCronMcpProbe = shouldRequireCronMcpProbe();
let cronJobId: string | undefined;
let lastCronAssistantText = "";
for (let attempt = 0; attempt < 2; attempt += 1) {
for (let attempt = 0; attempt < ACP_CRON_MCP_PROBE_MAX_ATTEMPTS; attempt += 1) {
await sendChatAndWait({
client,
sessionKey: originalSessionKey,
@@ -876,7 +909,7 @@ describeLive("gateway live (ACP bind)", () => {
cronHistory = await waitForAssistantText({
client,
sessionKey: spawnedSessionKey,
timeoutMs: liveAgent === "claude" ? 90_000 : 45_000,
timeoutMs: 20_000,
contains: cronProbe.name,
});
} catch {
@@ -885,13 +918,14 @@ describeLive("gateway live (ACP bind)", () => {
if (cronHistory) {
lastCronAssistantText = cronHistory.lastAssistantText;
}
const createdJob = await assertCronJobVisibleViaCli({
const verifyResult = await pollCronJobVisibleViaCli({
port,
token,
env: process.env,
expectedName: cronProbe.name,
expectedMessage: cronProbe.message,
});
const createdJob = verifyResult.job;
if (createdJob) {
assertCronJobMatches({
job: createdJob,
@@ -906,10 +940,15 @@ describeLive("gateway live (ACP bind)", () => {
}
break;
}
if (attempt === 1) {
if (liveAgent !== "claude") {
logLiveStep(
`cron mcp job not observed after attempt ${String(
attempt + 1,
)}; polls=${String(verifyResult.pollsUsed)}`,
);
if (attempt === ACP_CRON_MCP_PROBE_MAX_ATTEMPTS - 1) {
if (!requireCronMcpProbe) {
logLiveStep(
`cron mcp job ${cronProbe.name} not observed for ${liveAgent}; continuing after bind/image verification`,
`cron mcp job ${cronProbe.name} not observed; continuing after bind/image verification`,
);
break;
}
@@ -921,7 +960,7 @@ describeLive("gateway live (ACP bind)", () => {
}
}
if (!cronJobId) {
if (liveAgent !== "claude") {
if (!requireCronMcpProbe) {
return;
}
throw new Error(`acp cron cli verify did not create job ${cronProbe.name}`);

View File

@@ -31,6 +31,7 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js";
import { ensureOpenClawModelsJson } from "../agents/models-config.js";
import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js";
import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js";
import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -736,6 +737,16 @@ describe("shouldSkipEmptyResponseForLiveModel", () => {
);
});
describe("isEmptyStreamText", () => {
it.each([
{ text: "request ended without sending any chunks", expected: true },
{ text: `not meaningful: ${STREAM_ERROR_FALLBACK_TEXT}`, expected: true },
{ text: "not meaningful: let me think", expected: false },
])("returns $expected for $text", ({ text, expected }) => {
expect(isEmptyStreamText(text)).toBe(expected);
});
});
describe("isPromptProbeMiss", () => {
it.each([
{ error: "not meaningful: let me think", expected: true },
@@ -763,7 +774,10 @@ function isMissingProfileError(error: string): boolean {
}
function isEmptyStreamText(text: string): boolean {
return text.includes("request ended without sending any chunks");
return (
text.includes("request ended without sending any chunks") ||
text.includes(STREAM_ERROR_FALLBACK_TEXT)
);
}
function buildAnthropicRefusalToken(): string {

View File

@@ -74,6 +74,7 @@ export function buildLiveCronProbeMessage(params: {
if (params.attempt === 0) {
return (
"Use the OpenClaw MCP tool `openclaw-tools/cron` (server `openclaw-tools`, tool `cron`). " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Call it with JSON arguments ${params.argsJson}. ` +
"Preserve the JSON exactly, including job.sessionTarget and job.sessionKey; do not omit, rename, or flatten those fields. " +
"Do the actual tool call; I will verify externally with the OpenClaw cron CLI. " +
@@ -83,6 +84,7 @@ export function buildLiveCronProbeMessage(params: {
if (claudeLike) {
return (
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +
@@ -94,6 +96,7 @@ export function buildLiveCronProbeMessage(params: {
return (
"Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " +
"Retry the OpenClaw MCP tool `openclaw-tools/cron` now. " +
"If the harness shows Claude-style MCP names, use `mcp__openclaw-tools__cron` or `mcp__openclaw_tools__cron`. " +
`Use these exact JSON arguments: ${params.argsJson}. ` +
"Preserve job.sessionTarget and job.sessionKey exactly as provided. " +
`If the cron job is created, reply exactly: ${params.exactReply}. ` +

View File

@@ -50,6 +50,7 @@ function createTestPlugin(params?: {
order?: number;
account?: TestAccount;
startAccount?: NonNullable<ChannelPlugin<TestAccount>["gateway"]>["startAccount"];
listAccountIds?: ChannelPlugin<TestAccount>["config"]["listAccountIds"];
includeDescribeAccount?: boolean;
describeAccount?: ChannelPlugin<TestAccount>["config"]["describeAccount"];
resolveAccount?: ChannelPlugin<TestAccount>["config"]["resolveAccount"];
@@ -59,7 +60,7 @@ function createTestPlugin(params?: {
const account = params?.account ?? { enabled: true, configured: true };
const includeDescribeAccount = params?.includeDescribeAccount !== false;
const config: ChannelPlugin<TestAccount>["config"] = {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
listAccountIds: params?.listAccountIds ?? (() => [DEFAULT_ACCOUNT_ID]),
resolveAccount: params?.resolveAccount ?? (() => account),
isEnabled: (resolved) => resolved.enabled !== false,
...(params?.isConfigured ? { isConfigured: params.isConfigured } : {}),
@@ -436,6 +437,35 @@ describe("server-channels auto restart", () => {
expect(succeedingStart).toHaveBeenCalledTimes(1);
});
it("evicts stale account lifecycle state during whole-channel reload", async () => {
let accountIds = [DEFAULT_ACCOUNT_ID];
const startAccount = vi.fn(
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
await new Promise<void>((resolve) => {
abortSignal.addEventListener("abort", () => resolve(), { once: true });
}),
);
installTestRegistry(createTestPlugin({ startAccount, listAccountIds: () => accountIds }));
const manager = createManager();
await manager.startChannel("discord");
accountIds = [];
await manager.stopChannel("discord");
await manager.startChannel("discord");
accountIds = [DEFAULT_ACCOUNT_ID];
await manager.startChannel("discord");
const snapshot = manager.getRuntimeSnapshot();
const account = snapshot.channelAccounts.discord?.[DEFAULT_ACCOUNT_ID];
expect(startAccount).toHaveBeenCalledTimes(2);
expect(account?.reconnectAttempts).toBe(0);
expect(account?.lastStopAt).toBeUndefined();
await manager.stopChannel("discord");
});
it("reuses plugin account resolution for health monitor overrides", () => {
installTestRegistry(
createTestPlugin({

View File

@@ -282,6 +282,27 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
return channelRuntime ?? resolveChannelRuntime?.();
};
const evictStaleChannelAccountState = (
channelId: ChannelId,
store: ChannelRuntimeStore,
accountIds: readonly string[],
) => {
const activeAccountIds = new Set(accountIds);
for (const id of store.runtimes.keys()) {
if (
activeAccountIds.has(id) ||
store.aborts.has(id) ||
store.starting.has(id) ||
store.tasks.has(id)
) {
continue;
}
store.runtimes.delete(id);
restartAttempts.delete(restartKey(channelId, id));
manuallyStopped.delete(restartKey(channelId, id));
}
};
const startChannelInternal = async (
channelId: ChannelId,
accountId?: string,
@@ -297,6 +318,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
resetDirectoryCache({ channel: channelId, accountId });
const store = getStore(channelId);
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
if (!accountId) {
evictStaleChannelAccountState(channelId, store, accountIds);
}
if (accountIds.length === 0) {
return;
}

View File

@@ -312,10 +312,6 @@ describe("getCachedPluginJitiLoader", () => {
const loose = loader as unknown as (t: string, ...a: unknown[]) => unknown;
loose("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
expect(jitiLoader).toHaveBeenCalledWith(
"/repo/dist/extensions/demo/api.js",
{ hint: "x" },
42,
);
expect(jitiLoader).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { hint: "x" }, 42);
});
});

View File

@@ -363,7 +363,7 @@ export async function waitForChatFinalEvent(params: {
sessionKey: string;
timeoutMs?: number;
}): Promise<ChatEventPayload> {
const deadline = Date.now() + (params.timeoutMs ?? 15_000);
const deadline = Date.now() + (params.timeoutMs ?? 45_000);
while (Date.now() < deadline) {
const match = params.events.find(
(evt) =>