Compare commits

...

51 Commits

Author SHA1 Message Date
Peter Steinberger
095e1a90f5 docs(release): allow retagging unpublished betas 2026-04-26 19:39:52 +01:00
Peter Steinberger
227a07558b docs(changelog): place auto-reply backport in 2026.4.25 2026-04-26 19:33:19 +01:00
Vincent Koc
773e302179 fix(auto-reply): poison inbound dedupe after partial turn failure
* fix(auto-reply): poison inbound dedupe after replay-unsafe failures

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

* fix(runtime): harden dependency install surfaces

* fix(runtime): address dependency surface review

* fix(runtime): address dependency surface review

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

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

* test(commands): refresh current main checks

* test(commands): keep provider metadata mock unique

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

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

View File

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

View File

@@ -118,6 +118,24 @@ 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.
- 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/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.
- 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.
- 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.
- 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -290,7 +290,7 @@ async function bindConversationAndWait(params: {
if (runtime?.probeAvailability) {
await runtime.probeAvailability().catch(() => {});
}
if (!(backend?.healthy?.() ?? false)) {
if (!backend || (backend.healthy && !backend.healthy())) {
if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) {
const report = await runtime.doctor().catch((error) => ({
message: error instanceof Error ? error.message : String(error),

View File

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

View File

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