mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
61 Commits
codex/pr-8
...
v2026.4.25
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8972376cb | ||
|
|
377041cd75 | ||
|
|
d32a7916bd | ||
|
|
2c625f9368 | ||
|
|
5ea41fe40c | ||
|
|
cec1d46b30 | ||
|
|
a8ba87ee90 | ||
|
|
3f821a8888 | ||
|
|
1a3c480155 | ||
|
|
683437fe61 | ||
|
|
095e1a90f5 | ||
|
|
227a07558b | ||
|
|
773e302179 | ||
|
|
ec71b01f71 | ||
|
|
ca9fb36d53 | ||
|
|
1f194f1d55 | ||
|
|
a188d486dd | ||
|
|
a4266be808 | ||
|
|
90c40e9f90 | ||
|
|
b77514b6d9 | ||
|
|
a813219b6b | ||
|
|
4ac1406644 | ||
|
|
4d0e1470df | ||
|
|
6ecae22943 | ||
|
|
2c5ac5c0e2 | ||
|
|
8c309aa3de | ||
|
|
3c89b16fb0 | ||
|
|
ef447c43c7 | ||
|
|
ddb66a71af | ||
|
|
9b1583112a | ||
|
|
865fde8f72 | ||
|
|
ccc8d71461 | ||
|
|
a947464403 | ||
|
|
63803d78f4 | ||
|
|
dcad0256b2 | ||
|
|
12b1a63b84 | ||
|
|
6ea3f30b9b | ||
|
|
660dcf2c94 | ||
|
|
26ab654da2 | ||
|
|
5bc728d480 | ||
|
|
3779853ef9 | ||
|
|
b4ff947206 | ||
|
|
1e464867e7 | ||
|
|
ea9da71f03 | ||
|
|
1dbc246e29 | ||
|
|
41c7256420 | ||
|
|
b7733c48c0 | ||
|
|
50565b05aa | ||
|
|
2e10d87919 | ||
|
|
0ca3fae91a | ||
|
|
308ba59151 | ||
|
|
6ca5907692 | ||
|
|
b9758bf44a | ||
|
|
b923421129 | ||
|
|
c6276d6b19 | ||
|
|
399b41bbdb | ||
|
|
1ce1713139 | ||
|
|
1768995c37 | ||
|
|
ced0e96cf2 | ||
|
|
dd13141903 | ||
|
|
072a5ae4b0 |
@@ -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
|
||||
|
||||
46
CHANGELOG.md
46
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.26"
|
||||
"version": "2026.4.25"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}. ` +
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user